File Coverage

blib/lib/PAGI/Middleware/CSRF.pm
Criterion Covered Total %
statement 81 84 96.4
branch 19 22 86.3
condition 15 24 62.5
subroutine 15 15 100.0
pod 1 1 100.0
total 131 146 89.7


line stmt bran cond sub pod time code
1             package PAGI::Middleware::CSRF;
2              
3 2     2   393724 use strict;
  2         3  
  2         68  
4 2     2   21 use warnings;
  2         4  
  2         129  
5 2     2   7 use parent 'PAGI::Middleware';
  2         3  
  2         9  
6 2     2   99 use Future::AsyncAwait;
  2         2  
  2         7  
7 2     2   891 use Digest::SHA qw(sha256_hex);
  2         6628  
  2         164  
8 2     2   782 use PAGI::Utils::Random qw(secure_random_bytes);
  2         3  
  2         2534  
9              
10             =head1 NAME
11              
12             PAGI::Middleware::CSRF - Cross-Site Request Forgery protection middleware
13              
14             =head1 SYNOPSIS
15              
16             use PAGI::Middleware::Builder;
17              
18             my $app = builder {
19             enable 'CSRF',
20             secret => 'your-secret-key',
21             token_header => 'X-CSRF-Token',
22             cookie_name => 'csrf_token',
23             safe_methods => ['GET', 'HEAD', 'OPTIONS'];
24             $my_app;
25             };
26              
27             =head1 DESCRIPTION
28              
29             PAGI::Middleware::CSRF provides protection against Cross-Site Request
30             Forgery attacks by validating tokens on state-changing requests.
31              
32             =head1 CONFIGURATION
33              
34             =over 4
35              
36             =item * secret (required)
37              
38             Secret key used for token generation.
39              
40             =item * token_header (default: 'X-CSRF-Token')
41              
42             Header name to look for the CSRF token.
43              
44             =item * token_param (default: '_csrf_token')
45              
46             Form parameter name to look for the CSRF token.
47              
48             =item * cookie_name (default: 'csrf_token')
49              
50             Cookie name for the CSRF token.
51              
52             =item * safe_methods (default: ['GET', 'HEAD', 'OPTIONS', 'TRACE'])
53              
54             HTTP methods that don't require CSRF validation.
55              
56             =back
57              
58             =cut
59              
60             sub _init {
61 5     5   23 my ($self, $config) = @_;
62              
63 5   50     27 $self->{secret} = $config->{secret} // die "CSRF middleware requires 'secret' option";
64 5   50     29 $self->{token_header} = $config->{token_header} // 'X-CSRF-Token';
65 5   50     23 $self->{token_param} = $config->{token_param} // '_csrf_token';
66 5   50     19 $self->{cookie_name} = $config->{cookie_name} // 'csrf_token';
67 5   50     8 $self->{safe_methods} = { map { $_ => 1 } @{$config->{safe_methods} // [qw(GET HEAD OPTIONS TRACE)]} };
  20         54  
  5         32  
68             }
69              
70             sub wrap {
71 5     5 1 1124 my ($self, $app) = @_;
72              
73 8     8   1739 return async sub {
74 8         15 my ($scope, $receive, $send) = @_;
75             # Only handle HTTP requests
76 8 50       27 if ($scope->{type} ne 'http') {
77 0         0 await $app->($scope, $receive, $send);
78 0         0 return;
79             }
80              
81 8         15 my $method = $scope->{method};
82              
83             # Get existing token from cookie
84 8         38 my $cookie_token = $self->_get_cookie_token($scope);
85              
86             # Generate new token if none exists
87 8   66     30 my $token = $cookie_token // $self->_generate_token();
88              
89             # For safe methods, just add token to scope and continue
90 8 100       27 if ($self->{safe_methods}{$method}) {
91 3         29 my $modified_scope = $self->modify_scope($scope, {
92             csrf_token => $token,
93             });
94              
95             # Add Set-Cookie if token is new
96 6         318 my $wrapped_send = $cookie_token ? $send : async sub {
97 6         10 my ($event) = @_;
98 6 100       31 if ($event->{type} eq 'http.response.start') {
99 3         5 push @{$event->{headers}}, [
  3         15  
100             'Set-Cookie',
101             "$self->{cookie_name}=$token; Path=/; HttpOnly; SameSite=Strict"
102             ];
103             }
104 6         15 await $send->($event);
105 3 50       25 };
106              
107 3         11 await $app->($modified_scope, $receive, $wrapped_send);
108 3         319 return;
109             }
110              
111             # For unsafe methods, validate token
112 5         17 my $submitted_token = $self->_get_submitted_token($scope);
113              
114             # Use timing-safe comparison to prevent timing attacks
115 5 100 66     24 if (!$submitted_token || !$cookie_token || !$self->_secure_compare($submitted_token, $cookie_token)) {
      66        
116 3         10 await $self->_send_error($send, 403, 'CSRF token validation failed');
117 3         179 return;
118             }
119              
120             # Token valid, continue with request
121 2         20 my $modified_scope = $self->modify_scope($scope, {
122             csrf_token => $token,
123             });
124              
125 2         8 await $app->($modified_scope, $receive, $send);
126 5         33 };
127             }
128              
129             sub _generate_token {
130 5     5   14 my ($self) = @_;
131              
132             # Use cryptographically secure random bytes
133 5         20 my $random = secure_random_bytes(32);
134 5         144 return sha256_hex($self->{secret} . time() . $random . $$);
135             }
136              
137             sub _get_cookie_token {
138 8     8   15 my ($self, $scope) = @_;
139              
140 8         21 my $cookie_header = $self->_get_header($scope, 'cookie');
141 8 100       20 return unless $cookie_header;
142              
143 4         8 my $name = $self->{cookie_name};
144 4 50       144 if ($cookie_header =~ /(?:^|;\s*)\Q$name\E=([^;]+)/) {
145 4         41 return $1;
146             }
147 0         0 return;
148             }
149              
150             sub _get_submitted_token {
151 5     5   11 my ($self, $scope) = @_;
152              
153             # First check header
154 5         14 my $token = $self->_get_header($scope, $self->{token_header});
155 5 100       15 return $token if $token;
156              
157             # Could also check query string for token_param, but that requires
158             # parsing query string which we'll skip for now
159 2         5 return;
160             }
161              
162             sub _get_header {
163 13     13   26 my ($self, $scope, $name) = @_;
164              
165 13         24 $name = lc($name);
166 13   50     15 for my $h (@{$scope->{headers} // []}) {
  13         41  
167 11 100       36 return $h->[1] if lc($h->[0]) eq $name;
168             }
169 6         14 return;
170             }
171              
172             # Constant-time string comparison to prevent timing attacks
173             sub _secure_compare {
174 17     17   33 my ($self, $a, $b) = @_;
175              
176 17 100 100     60 return 0 unless defined $a && defined $b;
177 14 100       34 return 0 unless length($a) == length($b);
178              
179 9         10 my $result = 0;
180 9         20 for my $i (0 .. length($a) - 1) {
181 2198         2489 $result |= ord(substr($a, $i, 1)) ^ ord(substr($b, $i, 1));
182             }
183 9         37 return $result == 0;
184             }
185              
186 3     3   6 async sub _send_error {
187 3         6 my ($self, $send, $status, $message) = @_;
188              
189 3         21 await $send->({
190             type => 'http.response.start',
191             status => $status,
192             headers => [
193             ['content-type', 'text/plain'],
194             ['content-length', length($message)],
195             ],
196             });
197 3         145 await $send->({
198             type => 'http.response.body',
199             body => $message,
200             more => 0,
201             });
202             }
203              
204             1;
205              
206             __END__