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   390761 use strict;
  2         4  
  2         86  
4 2     2   7 use warnings;
  2         9  
  2         100  
5 2     2   10 use parent 'PAGI::Middleware';
  2         1  
  2         11  
6 2     2   99 use Future::AsyncAwait;
  2         4  
  2         9  
7 2     2   927 use Digest::SHA qw(sha256_hex);
  2         7148  
  2         191  
8 2     2   761 use PAGI::Utils::Random qw(secure_random_bytes);
  2         6  
  2         2699  
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   17 my ($self, $config) = @_;
62              
63 5   50     18 $self->{secret} = $config->{secret} // die "CSRF middleware requires 'secret' option";
64 5   50     22 $self->{token_header} = $config->{token_header} // 'X-CSRF-Token';
65 5   50     20 $self->{token_param} = $config->{token_param} // '_csrf_token';
66 5   50     16 $self->{cookie_name} = $config->{cookie_name} // 'csrf_token';
67 5   50     6 $self->{safe_methods} = { map { $_ => 1 } @{$config->{safe_methods} // [qw(GET HEAD OPTIONS TRACE)]} };
  20         44  
  5         25  
68             }
69              
70             sub wrap {
71 5     5 1 731 my ($self, $app) = @_;
72              
73 8     8   1722 return async sub {
74 8         12 my ($scope, $receive, $send) = @_;
75             # Only handle HTTP requests
76 8 50       21 if ($scope->{type} ne 'http') {
77 0         0 await $app->($scope, $receive, $send);
78 0         0 return;
79             }
80              
81 8         10 my $method = $scope->{method};
82              
83             # Get existing token from cookie
84 8         19 my $cookie_token = $self->_get_cookie_token($scope);
85              
86             # Generate new token if none exists
87 8   66     21 my $token = $cookie_token // $self->_generate_token();
88              
89             # For safe methods, just add token to scope and continue
90 8 100       20 if ($self->{safe_methods}{$method}) {
91 3         18 my $modified_scope = $self->modify_scope($scope, {
92             csrf_token => $token,
93             });
94              
95             # Add Set-Cookie if token is new
96 6         339 my $wrapped_send = $cookie_token ? $send : async sub {
97 6         9 my ($event) = @_;
98 6 100       16 if ($event->{type} eq 'http.response.start') {
99 3         3 push @{$event->{headers}}, [
  3         14  
100             'Set-Cookie',
101             "$self->{cookie_name}=$token; Path=/; HttpOnly; SameSite=Strict"
102             ];
103             }
104 6         12 await $send->($event);
105 3 50       20 };
106              
107 3         9 await $app->($modified_scope, $receive, $wrapped_send);
108 3         204 return;
109             }
110              
111             # For unsafe methods, validate token
112 5         9 my $submitted_token = $self->_get_submitted_token($scope);
113              
114             # Use timing-safe comparison to prevent timing attacks
115 5 100 66     19 if (!$submitted_token || !$cookie_token || !$self->_secure_compare($submitted_token, $cookie_token)) {
      66        
116 3         11 await $self->_send_error($send, 403, 'CSRF token validation failed');
117 3         186 return;
118             }
119              
120             # Token valid, continue with request
121 2         16 my $modified_scope = $self->modify_scope($scope, {
122             csrf_token => $token,
123             });
124              
125 2         6 await $app->($modified_scope, $receive, $send);
126 5         23 };
127             }
128              
129             sub _generate_token {
130 5     5   11 my ($self) = @_;
131              
132             # Use cryptographically secure random bytes
133 5         16 my $random = secure_random_bytes(32);
134 5         116 return sha256_hex($self->{secret} . time() . $random . $$);
135             }
136              
137             sub _get_cookie_token {
138 8     8   12 my ($self, $scope) = @_;
139              
140 8         22 my $cookie_header = $self->_get_header($scope, 'cookie');
141 8 100       17 return unless $cookie_header;
142              
143 4         6 my $name = $self->{cookie_name};
144 4 50       128 if ($cookie_header =~ /(?:^|;\s*)\Q$name\E=([^;]+)/) {
145 4         15 return $1;
146             }
147 0         0 return;
148             }
149              
150             sub _get_submitted_token {
151 5     5   8 my ($self, $scope) = @_;
152              
153             # First check header
154 5         14 my $token = $self->_get_header($scope, $self->{token_header});
155 5 100       11 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         4 return;
160             }
161              
162             sub _get_header {
163 13     13   16 my ($self, $scope, $name) = @_;
164              
165 13         18 $name = lc($name);
166 13   50     13 for my $h (@{$scope->{headers} // []}) {
  13         36  
167 11 100       29 return $h->[1] if lc($h->[0]) eq $name;
168             }
169 6         11 return;
170             }
171              
172             # Constant-time string comparison to prevent timing attacks
173             sub _secure_compare {
174 17     17   34 my ($self, $a, $b) = @_;
175              
176 17 100 100     56 return 0 unless defined $a && defined $b;
177 14 100       32 return 0 unless length($a) == length($b);
178              
179 9         10 my $result = 0;
180 9         18 for my $i (0 .. length($a) - 1) {
181 2198         2453 $result |= ord(substr($a, $i, 1)) ^ ord(substr($b, $i, 1));
182             }
183 9         30 return $result == 0;
184             }
185              
186 3     3   4 async sub _send_error {
187 3         8 my ($self, $send, $status, $message) = @_;
188              
189 3         17 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         134 await $send->({
198             type => 'http.response.body',
199             body => $message,
200             more => 0,
201             });
202             }
203              
204             1;
205              
206             __END__