File Coverage

blib/lib/Dancer2/Plugin/CSRFI.pm
Criterion Covered Total %
statement 86 93 92.4
branch 15 20 75.0
condition 4 5 80.0
subroutine 19 20 95.0
pod 2 8 25.0
total 126 146 86.3


line stmt bran cond sub pod time code
1              
2             use v5.24;
3 3     3   1559638 use strict;
  3         24  
4 3     3   16 use warnings;
  3         5  
  3         54  
5 3     3   13  
  3         5  
  3         65  
6             use Dancer2::Plugin;
7 3     3   1381 use Dancer2::Core::Hook;
  3         265460  
  3         26  
8 3     3   50135 use List::Util qw(any);
  3         8  
  3         69  
9 3     3   14 use Crypt::SaltedHash;
  3         6  
  3         174  
10 3     3   1425 use Data::UUID;
  3         9044  
  3         107  
11 3     3   1147 use URI::Split qw(uri_split uri_join);
  3         1702  
  3         179  
12 3     3   1133  
  3         4667  
  3         3259  
13             our $VERSION = '1.02';
14              
15             plugin_keywords qw(csrf_token validate_csrf);
16              
17             plugin_hooks qw(after_validate_csrf);
18              
19             has session_key => (
20             is => 'ro',
21             lazy => 1,
22             default => sub { $_[0]->config->{session_key} || '_csrf' }
23             );
24              
25             has refresh => (
26             is => 'ro',
27             lazy => 1,
28             default => sub { $_[0]->config->{refresh} || 0 }
29             );
30              
31             has template_token => (
32             is => 'ro',
33             lazy => 1,
34             default => sub { $_[0]->config->{template_token} }
35             );
36              
37             has validate_post => (
38             is => 'ro',
39             lazy => 1,
40             default => sub { $_[0]->config->{validate_post} || 0 }
41             );
42              
43             has field_name => (
44             is => 'ro',
45             lazy => 1,
46             default => sub { $_[0]->config->{field_name} || 'csrf_token' }
47             );
48              
49             has error_status => (
50             is => 'ro',
51             lazy => 1,
52             default => sub { $_[0]->config->{error_status} || 403 }
53             );
54              
55             has error_message => (
56             is => 'ro',
57             lazy => 1,
58             default => sub { $_[0]->config->{error_message} || 'Forbidden' }
59             );
60              
61             my ($self) = @_;
62              
63 5     5 0 64522 if ($self->validate_post) {
64             $self->app->add_hook(
65 5 100       70 Dancer2::Core::Hook->new(
66             name => 'before',
67             code => sub { $self->hook_before_request_validate_csrf(@_) },
68             )
69 10     10   246227 );
70             }
71 3         158  
72             if (my $token = $self->template_token) {
73             $self->app->add_hook(
74 5 50       1225 Dancer2::Core::Hook->new(
75             name => 'before_template_render',
76             code => sub { $_[0]->{$token} = $self->csrf_token }
77             )
78 0     0   0 );
79             }
80 0         0  
81             return;
82             }
83 5         49  
84             my ($self) = @_;
85              
86             my $unique;
87 14     14 1 208546 my $salt;
88             my $hasher;
89 14         32 my $entropy = $self->page_entropy;
90             my $session = $self->app->session->read($self->session_key);
91 14         0  
92 14         30 if (defined $session and not $self->refresh) {
93 14         324 $unique = $session->{unique};
94             $salt = $session->{salt};
95 14 50 66     4912 $hasher = Crypt::SaltedHash->new(salt => $salt);
96 0         0 }
97 0         0 else {
98 0         0 $unique = $self->unique;
99             $hasher = Crypt::SaltedHash->new;
100             $salt = $hasher->salt_hex;
101 14         41 }
102 14         144  
103 14         1470 $self->app->session->write(
104             $self->session_key => { unique => $unique, salt => $salt }
105             );
106 14         427  
107             return $hasher->add($unique, $entropy)->generate;
108             }
109              
110 14         1144 my ($self, $token) = @_;
111              
112             if (not defined $token) {
113             return;
114 12     12 1 40985 }
115              
116 12 50       27 my $session = $self->app->session->read($self->session_key);
117 0         0  
118             if (not defined $session) {
119             return;
120 12         191 }
121              
122 12 100       8179 my $salt = $session->{salt};
123 5         14 my $unique = $session->{unique};
124             my $hasher = Crypt::SaltedHash->new(salt => $salt);
125             my $entropy = $self->referer_entropy;
126 7         14  
127 7         11 my $expected = $hasher->add($unique, $entropy)->generate;
128 7         31  
129 7         370 return $token eq $expected;
130             }
131 7         65  
132             my ($self) = @_;
133 7         253  
134             my $base = $self->app->request->uri_base;
135             my $path = $self->app->request->path;
136              
137 14     14 0 23 # To prevent //.
138             $path = $path eq '/' ? '' : $path;
139 14         60  
140 14         3887 return $self->entropy($base . $path);
141             }
142              
143 14 50       93 my ($self) = @_;
144              
145 14         36 my $referer = $self->app->request->referer || '';
146              
147             # To remove everything after ?.
148             my ($scheme, $auth, $path) = uri_split($referer);
149 7     7 0 16  
150             return $self->entropy(
151 7   100     50 uri_join($scheme, $auth, $path),
152             );
153             }
154 7         245  
155             my ($self, $path) = @_;
156 7         65 return sprintf(
157             '%s:%s',
158             $path,
159             $self->app->request->address
160             );
161             }
162 21     21 0 170  
163 21         71 return Data::UUID->new->create_str;
164             }
165              
166             my ($self, $app) = @_;
167              
168             if (not $app->request->is_post) {
169             return;
170             }
171 14     14 0 15844  
172             my $content_type = $app->request->content_type;
173             my @html_form_enctype = qw(application/x-www-form-urlencoded multipart/form-data);
174              
175 10     10 0 15 if (not any { $_ eq $content_type } @html_form_enctype) {
176             return;
177 10 100       36 }
178 5         52  
179             my $token = $app->request->body_parameters->{$self->field_name};
180             my $success = $self->validate_csrf($token);
181 5         54 my $referer = $app->request->referer;
182 5         29  
183             if (not $success) {
184 5 50   5   23 $self->app->log(
  5         17  
185 0         0 info => {
186             message => __PACKAGE__ . ': Token is not valid',
187             referer => $referer,
188 5         18 }
189 5         86 );
190 5         20 }
191             else {
192 5 100       134 $self->app->log(
193 3         15 debug => {
194             message => __PACKAGE__ . ': Token is valid',
195             referer => $referer,
196             }
197             );
198             }
199              
200             my %after_validate_bag = (
201 2         11 success => $success,
202             referer => $referer,
203             error_status => $self->error_status,
204             error_message => $self->error_message,
205             );
206              
207             $self->app->log(
208             debug => {
209 5         2896 message => __PACKAGE__ . ': Entering after_validate_csrf hook',
210             referer => $referer,
211             }
212             );
213              
214             $self->execute_plugin_hook(
215             'after_validate_csrf',
216 5         94 $app,
217             \%after_validate_bag,
218             );
219              
220             if ($success) {
221             return;
222             }
223 5         2467  
224             $self->app->log(
225             info => {
226             message => __PACKAGE__ . ': Sending error',
227             referer => $referer,
228             error_status => $after_validate_bag{error_status},
229 5 100       998 error_message => $after_validate_bag{error_message},
230 2         10 }
231             );
232              
233             $app->send_error(
234             $after_validate_bag{error_message},
235             $after_validate_bag{error_status},
236             );
237             }
238              
239             1;
240 3         22  
241             # ABSTRACT: Dancer2 CSRF protection plugin.
242              
243             =pod
244              
245 3         1457 =encoding UTF-8
246              
247             =head1 NAME
248              
249             Dancer2::Plugin::CSRFI - Improved CSRF token generation and validation.
250              
251             =head1 VERSION
252              
253             version 1.02
254              
255             =head1 SYNOPSIS
256              
257             use Dancer2;
258             use Dancer2::Plugin::CSRFI;
259              
260             set plugins => {
261             CSRFI => {
262             validate_post => 1, # this will automate token validation.
263             template_token => 'csrf_token', # token named 'csrf_token' will be available in templates.
264             }
265             }
266              
267             get '/form' => sub {
268             template 'form';
269             };
270              
271             # This route (and other post) is protected with csrf token.
272             post '/form' => sub {
273             save_data(body_parameters);
274             };
275              
276             =head1 DESCRIPTION
277              
278             This module is inspired by L<Dancer2::Plugin::CSRF|https://metacpan.org/pod/Dancer2::Plugin::CSRF>
279             and L<Plack::Middleware::CSRFBlock|https://metacpan.org/pod/Plack::Middleware::CSRFBlock>.
280              
281             But it's fresh (2022 year release) and will be supported.
282              
283             =head2 Capabilities
284              
285             =over 4
286              
287             =item *
288             Сan be used in multi-application mode.
289              
290             =item *
291             Сan issue and verify CSRF token.
292              
293             =item *
294             Can automatically check the token for post requests.
295              
296             =item *
297             Has useful hooks (so far one).
298              
299             =back
300              
301             =head2 WHY USE CSRF TOKEN
302              
303             If you are unfamiliar with this topic or want to learn more, read this
304             L<Cross-Site Request Forgery Prevention Cheat Sheet|https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html>.
305              
306             =head1 DSL KEYWORDS
307              
308             =head3 csrf_token
309              
310             csrf_token(): Str
311              
312             Generate CSRF token.
313              
314             =head3 validate_csrf
315              
316             validate_csrf(Str $token): Bool
317              
318             Validate CSRF token.
319              
320             =head1 CONFIGURATION
321              
322             ...
323             plugins:
324             CSRFI:
325             session_key: _csrf # this is default
326             refresh: 0 # this is default
327             template_token: csrf_token
328             validate_post: 0 # this is default
329             field_name: csrf_token # this is default
330             error_status: 403 # this is default
331             error_message: Forbidden # this is default
332             ...
333              
334             =head3 session_key
335              
336             Session storage key where this module stores data.
337              
338             =head3 refresh
339              
340             If true, token will be refreshed on each hit.
341             This makes your applications more secure, but in many cases, is too strict.
342              
343             =head3 template_token
344              
345             If provided, template token with csrf token will be set.
346              
347             =head3 validate_post
348              
349             If true, token will be automatically validates each post request with
350             content-types application/x-www-form-urlencoded or multipart/form-data.
351              
352             =head3 field_name
353              
354             Filed name in body-parameters sent with post request, where this module will try
355             to find csrf token, when validate_post is enabled.
356              
357             =head3 error_status
358              
359             Error with this status will be send if validate_post is enabled.
360              
361             =head3 error_message
362              
363             Error with this message will be send if validate_post is enabled.
364              
365             =head1 HOOKS
366              
367             =head3 after_validate_csrf
368              
369             Fires if validate_post is enabled. After validating the token but before sending the error.
370              
371             # Two arguments: Dancer2 app + module args.
372             hook after_validate_csrf => sub {
373             my ($app, $args) = @_;
374             log $args;
375             redirect '/error';
376             };
377              
378             # Args structure.
379             $args = {
380             success => $success,
381             referer => $referer,
382             error_status => $error_status,
383             error_message => $error_message,
384             };
385              
386             You could change $args values by ref, then module will continue to operate with the changed values.
387              
388             =head1 OTHER USEFUL PLUGINS
389              
390             =over 4
391              
392             =item *
393             L<Dancer2::Plugin::FormValidator|https://metacpan.org/pod/Dancer2::Plugin::FormValidator>
394              
395             =back
396              
397             =head1 BUGS AND LIMITATIONS
398              
399             If you find one, please let me know.
400              
401             =head1 SOURCE CODE REPOSITORY
402              
403             L<https://github.com/AlexP007/dancer2-plugin-csrfi|https://github.com/AlexP007/dancer2-plugin-csrfi>.
404              
405             =head1 AUTHOR
406              
407             Alexander Panteleev <alexpan at cpan dot org>.
408              
409             =head1 LICENSE AND COPYRIGHT
410              
411             This software is copyright (c) 2022 by Alexander Panteleev.
412             This is free software; you can redistribute it and/or modify it under
413             the same terms as the Perl 5 programming language system itself.
414              
415             =cut