File Coverage

blib/lib/Dancer2/Plugin/CSRFI.pm
Criterion Covered Total %
statement 85 92 92.3
branch 15 20 75.0
condition 4 5 80.0
subroutine 19 20 95.0
pod 2 8 25.0
total 125 145 86.2


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