File Coverage

blib/lib/Catalyst/Plugin/CSRFToken.pm
Criterion Covered Total %
statement 60 81 74.0
branch 25 68 36.7
condition 3 12 25.0
subroutine 16 20 80.0
pod n/a
total 104 181 57.4


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