File Coverage

blib/lib/Dancer2/Plugin/CSRFI.pm
Criterion Covered Total %
statement 81 88 92.0
branch 15 20 75.0
condition 4 5 80.0
subroutine 18 19 94.7
pod 2 8 25.0
total 120 140 85.7


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