File Coverage

blib/lib/Catalyst/Plugin/CSRFToken.pm
Criterion Covered Total %
statement 61 84 72.6
branch 27 74 36.4
condition 3 12 25.0
subroutine 16 20 80.0
pod n/a
total 107 190 56.3


line stmt bran cond sub pod time code
1             package Catalyst::Plugin::CSRFToken;
2              
3 1     1   1736860 use Moo::Role;
  1         21383  
  1         4  
4 1     1   4059 use WWW::CSRF ();
  1         16887  
  1         22  
5 1     1   7 use Bytes::Random::Secure ();
  1         2  
  1         1341  
6              
7              
8             our $VERSION = '0.005';
9            
10             has 'default_csrf_token_secret' => (is=>'ro', required=>1, builder=>'_build_default_csrf_token_secret');
11            
12             sub _build_default_csrf_token_secret {
13 6 50   6   29661 if(my $config = shift->config->{'Plugin::CSRFToken'}) {
14 6 50       556 return $config->{default_secret} if exists $config->{default_secret};
15             }
16 0         0 return;
17             }
18              
19             has 'default_csrf_token_max_age' => (is=>'ro', required=>1, builder=>'_build_default_csrf_token_max_age');
20            
21             sub _build_default_csrf_token_max_age {
22 6 50   6   9666 if(my $config = shift->config->{'Plugin::CSRFToken'}) {
23 6 50       541 return $config->{max_age} if exists $config->{max_age};
24             }
25 6         18 return 60*60; # One hour in seconds
26             }
27              
28             has 'csrf_token_param_key' => (is=>'ro', required=>1, builder=>'_build_csrf_token_param_key');
29            
30             sub _build_csrf_token_param_key {
31 6 50   6   23332 if(my $config = shift->config->{'Plugin::CSRFToken'}) {
32 6 50       601 return $config->{param_key} if exists $config->{param_key};
33             }
34 6         20 return 'csrf_token';
35             }
36              
37             has 'auto_check_csrf_token' => (is=>'ro', required=>1, builder=>'_build_auto_check_csrf_token');
38            
39             sub _build_auto_check_csrf_token {
40 6 50   6   16565 if(my $config = shift->config->{'Plugin::CSRFToken'}) {
41 6 50       546 return $config->{auto_check} if exists $config->{auto_check};
42             }
43 0         0 return 0;
44             }
45              
46             sub default_csrf_session_id {
47 5     5   13 my $self = shift;
48 5         21 return $self->sessionid;
49             }
50              
51             sub random_token {
52 1     1   4 my ($self, $length) = @_;
53 1 50       4 $length = 48 unless $length;
54 1         11 return Bytes::Random::Secure::random_bytes_base64($length,'');
55             }
56              
57             sub single_use_csrf_token {
58 1     1   3266 my ($self) = @_;
59 1         5 my $token = $self->random_token;
60 1         144 $self->session(current_csrf_token => $token);
61 1         37 return $token;
62             }
63              
64             sub check_single_use_csrf_token {
65 3     3   10 my ($self, %args) = @_;
66 3 50       14 my $token = exists($args{csrf_token}) ? $args{csrf_token} : $self->find_csrf_token_in_request;
67 3 50       363 return 0 unless $token;
68 3 100       25 if(my $session_token = delete($self->session->{current_csrf_token})) {
69 1 50       19 return $session_token eq $token ? 1:0;
70             } else {
71 2         28 return 0;
72             }
73             }
74              
75             sub csrf_token {
76 1     1   3870 my ($self, %args) = @_;
77 1 50       7 my $session = exists($args{session}) ? $args{session} : $self->default_csrf_session_id;
78 1 50       33 my $token_secret = exists($args{token_secret}) ? $args{token_secret} : $self->default_csrf_token_secret;
79              
80 1         13 return my $token = WWW::CSRF::generate_csrf_token($session, $token_secret);
81             }
82              
83             sub find_csrf_token_in_request {
84 7     7   15 my $self = shift;
85 7 50       143 if(my $header_token = $self->request->header('X-CSRF-Token')) {
86 0         0 return $header_token;
87             } else {
88             return $self->req->body_parameters->{$self->csrf_token_param_key}
89 7 50       631 if exists($self->req->body_parameters->{$self->csrf_token_param_key});
90             return $self->req->body_data->{$self->csrf_token_param_key}
91 0 0       0 if exists($self->req->body_data->{$self->csrf_token_param_key});
92 0         0 return undef;
93             }
94             }
95              
96             sub is_csrf_token_expired {
97 0     0   0 my ($self, %args) = @_;
98 0 0       0 my $token = exists($args{csrf_token}) ? $args{csrf_token} : $self->find_csrf_token_in_request;
99 0 0       0 my $session = exists($args{session}) ? $args{session} : $self->default_csrf_session_id;
100 0 0       0 my $token_secret = exists($args{token_secret}) ? $args{token_secret} : $self->default_csrf_token_secret;
101 0 0       0 my $max_age = exists($args{max_age}) ? $args{max_age} : $self->default_csrf_token_max_age;
102              
103 0 0       0 return 0 unless $token;
104 0 0       0 return 1 if WWW::CSRF::check_csrf_token(
105             $session,
106             $token_secret,
107             $token,
108             +{ MaxAge=>$max_age }
109             ) == WWW::CSRF::CSRF_EXPIRED;
110            
111 0         0 return 0;
112             }
113              
114             sub invalid_csrf_token {
115 0 0   0   0 return shift->(@_)->check_csrf_token ? 0:1;
116             }
117              
118             sub check_csrf_token {
119 4     4   282 my ($self, %args) = @_;
120 4 50       18 my $token = exists($args{csrf_token}) ? $args{csrf_token} : $self->find_csrf_token_in_request;
121 4 50       593 my $session = exists($args{session}) ? $args{session} : $self->default_csrf_session_id;
122 4 50       26 my $token_secret = exists($args{token_secret}) ? $args{token_secret} : $self->default_csrf_token_secret;
123 4 50       34 my $max_age = exists($args{max_age}) ? $args{max_age} : $self->default_csrf_token_max_age;
124            
125 4 50       26 return 0 unless $token;
126              
127 4         32 my $status = WWW::CSRF::check_csrf_token(
128             $session,
129             $token_secret,
130             $token,
131             +{ MaxAge=>$max_age }
132             );
133 4         211 $self->stash(_last_csrf_token_status => $status);
134              
135 4 100       336 return 0 unless $status == WWW::CSRF::CSRF_OK;
136 1         9 return 1;
137             }
138              
139             sub last_checked_csrf_token_expired {
140 0     0   0 my ($self) = @_;
141 0         0 my $status = $self->stash->{_last_csrf_token_status};
142              
143 0 0       0 return Catalyst::Exception->throw(message => 'csrf_token has not been checked yet') unless defined $status;
144 0 0       0 return $status == WWW::CSRF::CSRF_EXPIRED ? 1:0;
145             }
146              
147             sub delegate_failed_csrf_token_check {
148 2     2   7 my $self = shift;
149 2 50       14 return $self->controller->handle_failed_csrf_token_check($self) if $self->controller->can('handle_failed_csrf_token_check');
150 2 50       239 return $self->handle_failed_csrf_token_check if $self->can('handle_failed_csrf_token_check');
151              
152             # If we get this far we need to create a rational default error response and die
153 2         48 $self->response->status(403);
154 2         267 $self->response->content_type('text/plain');
155 2         560 $self->response->body('Forbidden: Invalid CSRF token.');
156 2         79 $self->finalize;
157 2         7040 Catalyst::Exception->throw(message => 'csrf_token failed validation');
158             }
159              
160             sub validate_csrf_token_if_required {
161 6     6   61 my $self = shift;
162             return (
163             (
164 6   100     16 ($self->req->method eq 'POST') ||
165             ($self->req->method eq 'PUT') ||
166             ($self->req->method eq 'PATCH')
167             )
168             &&
169             (
170             !$self->check_csrf_token &&
171             !$self->check_single_use_csrf_token
172             )
173             );
174             }
175              
176             sub process_csrf_token {
177 0     0     my $self = shift;
178 0 0 0       return 1 unless (
      0        
179             ($self->req->method eq 'POST') ||
180             ($self->req->method eq 'PUT') ||
181             ($self->req->method eq 'PATCH')
182             );
183              
184 0 0 0       if($self->can('session') && $self->session->{current_csrf_token}) {
185 0           return $self->check_single_use_csrf_token;
186             } else {
187 0           return $self->check_csrf_token;
188             }
189             }
190              
191             around 'dispatch', sub {
192             my ($orig, $self, @args) = @_;
193             if(
194             $self->auto_check_csrf_token
195             &&
196             $self->validate_csrf_token_if_required
197             ) {
198             return $self->delegate_failed_csrf_token_check;
199             }
200             return $self->$orig(@args);
201             };
202              
203             1;
204              
205             =head1 NAME
206              
207             Catalyst::Plugin::CSRFToken - Generate tokens to help prevent CSRF attacks
208              
209             =head1 SYNOPSIS
210            
211             package MyApp;
212             use Catalyst;
213              
214             # The default functionality of this plugin expects a method 'sessionid' which
215             # is associated with the current user session. This method is provided by the
216             # session plugin but you can provide your own or override 'default_csrf_session_id'
217             # if you know what you are doing!
218              
219             MyApp->setup_plugins([qw/
220             Session
221             Session::State::Cookie
222             Session::Store::Cookie
223             CSRFToken
224             /]);
225              
226             MyApp->config(
227             'Plugin::CSRFToken' => { default_secret=>'changeme', auto_check_csrf_token => 1 }
228             );
229            
230             MyApp->setup;
231            
232             package MyApp::Controller::Root;
233            
234             use Moose;
235             use MooseX::MethodAttributes;
236            
237             extends 'Catalyst::Controller';
238            
239             sub login_form :Path(login_form) Args(0) {
240             my ($self, $c) = @_;
241              
242             # A Basic manual check example if you leave 'auto_check_csrf_token' off (default)
243             if($c->req->method eq 'POST') {
244             Catalyst::Exception->throw(message => 'csrf_token failed validation')
245             unless $c->check_csrf_token;
246             }
247              
248             $c->stash(csrf_token => $c->csrf_token); # send a token to your view and make sure you
249             # add it to your form as a hidden field
250             }
251            
252             =head1 DESCRIPTION
253              
254             This uses L<WWW::CSRF> to generate hard to guess tokens tied to a give web session. You can
255             generate a token and pass it to your view layer where it should be added to the form you are
256             trying to process, typically as a hidden field called 'csrf_token' (althought you can change
257             that in configuration if needed).
258              
259             Its probably best to enable 'auto_check_csrf_token' true since that will automatically check
260             all POST, bPUT and PATCH request (but of course if you do this you have to be sure to add the token
261             to every single form. If you need to just use this on a few forms (for example you have a
262             large legacy application and need to improve security in steps) you can roll your own handling
263             via the C<check_csrf_token> method as in the example given above.
264              
265             =head1 METHODS
266              
267             This Plugin adds the following methods
268              
269             =head2 random_token
270              
271             This just returns base64 random string that is cryptographically secure and is generically
272             useful for anytime you just need a random token. Default length is 48 but please note
273             that the actual base64 length will be longer.
274              
275             =head2 csrf_token ($session, $token_secret)
276              
277             Generates a token for the current request path and user session and returns this string
278             in a form suitable to put into an HTML form hidden field value. Accepts the following
279             positional arguments:
280              
281             =over 4
282              
283             =item $session
284              
285             This is a string of data which is somehow linked to the current user session. The default
286             is to call the method 'default_csrf_session_id' which currently just returns the value of
287             '$c->sessionid'. You can pass something here if you want a tigher scope (for example you
288             want a token that is scoped to both the current user id and a given URL path).
289              
290             =item $token_secret
291              
292             Default is whatever you set the configuration value 'default_secret' to.
293              
294             =back
295              
296             =head2 check_csrf_token
297              
298             Return true or false depending on if the current request has a token which is valid. Accepts the
299             following arguments in the form of a hash:
300              
301             =over 4
302              
303             =item csrf_token
304              
305             The token to check. Default behavior is to invoke method C<find_csrf_token_in_request> which
306             looks in the HTTP request header and body parameters for the token. Set this to validate a
307             specific token.
308              
309             =item session
310              
311             This is a string of data which is somehow linked to the current user session. The default
312             is to call the method 'default_csrf_session_id' which currently just returns the value of
313             '$c->sessionid'. You can pass something here if you want a tigher scope (for example you
314             want a token that is scoped to both the current user id and a given URL path).
315              
316             It should match whatever you passed to C<csrf_token> for the request token you are trying to validate.
317              
318             =item token_secret
319              
320             Default is whatever you set the configuration value 'default_secret' to. Allows you to specify a
321             custom secret (it should match whatever you passed to C<csrf_token>).
322              
323             =item max_age
324              
325             Defaults to whatever you set configuration value <max_age>. A value in seconds that measures how
326             long a token is considered 'not expired'. I recommend setting this to as short a value as is
327             reasonable for your users to linger on a form page.
328              
329             =back
330              
331             Example:
332              
333             $c->check_csrf_token(max_age=>(60*10)); # Don't accept a token that is older than 10 minutes.
334              
335             B<NOTE>: If the token
336              
337             =head2 invalid_csrf_token
338              
339             Returns true if the token is invalid. This is just the inverse of 'check_csrf_token' and
340             it accepts the same arguments.
341              
342             =head2 last_checked_csrf_token_expired
343              
344             Return true if the last checked token was considered expired based on the arguments used to
345             check it. Useful if you are writing custom checking code that wants to return a different
346             error if the token was well formed but just too old. Throws an exception if you haven't
347             actually checked a token.
348              
349             =head2 single_use_csrf_token
350              
351             Creates a token that is saved in the session. Unlike 'csrf_token' this token is not crytographically
352             signed so intead its saved in the user session and can only be used once. You might prefer
353             this approach for classic HTML forms while the other approach could be better for API applications
354             where you don't want the overhead of a user session (or where you'd like the client to be able to
355             open multiply connections at once.
356              
357              
358             =head2 check_single_use_csrf_token
359              
360             Checks a single_use_csrf_token. Accepts the token to check but defaults to getting it from
361             the request if not provided.
362              
363             =head1 CONFIGURATION
364              
365             This plugin permits the following configurations keys
366              
367             =head2 default_secret
368              
369             String that is used in part to generate the token to help ensure its hard to guess.
370              
371             =head2 max_age
372              
373             Default to 3600 seconds (one hour). This is the length of time before the generated token
374             is considered expired. One hour is probably too long. You should set it to the shortest
375             time reasonable.
376              
377             =head2 param_key
378              
379             Defaults to 'csrf_token'. The Body param key we look for the token.
380              
381             =head2 auto_check_csrf_token
382              
383             Defaults to false. When set to true we automatically do a check for all POST, PATCH and
384             PUT method requests and if the check fails we delegate handling in the following way:
385              
386             If the current controller does a method called 'handle_failed_csrf_token_check' we invoke that
387             passing the current context.
388              
389             Else if the application class does a method called 'handle_failed_csrf_token_check' we invoke
390             that instead.
391              
392             Failing either of those we just throw an exception and set a rational message body (403 Forbidden:
393             Bad CSRF token). In all cases if there's a CSRF error we skip the 'dispatch' phase so none of
394             your actions will run, including any global 'end' actions.
395              
396             =head1 AUTHOR
397              
398             John Napiorkowski <jnapiork@cpan.org>
399            
400             =head1 COPYRIGHT
401            
402             Copyright (c) 2023 the above named AUTHOR
403            
404             =head1 LICENSE
405            
406             You may distribute this code under the same terms as Perl itself.
407            
408             =cut