File Coverage

lib/Plack/Middleware/XSRFBlock.pm
Criterion Covered Total %
statement 137 142 96.4
branch 38 52 73.0
condition 32 56 57.1
subroutine 27 27 100.0
pod 11 11 100.0
total 245 288 85.0


line stmt bran cond sub pod time code
1             package Plack::Middleware::XSRFBlock;
2              
3             {
4             $Plack::Middleware::XSRFBlock::DIST = 'Plack-Middleware-XSRFBlock';
5             }
6             $Plack::Middleware::XSRFBlock::VERSION = '0.0.19';
7 9     9   132676 use strict;
  9         28  
  9         275  
8 9     9   51 use warnings;
  9         18  
  9         240  
9 9     9   575 use parent 'Plack::Middleware';
  9         341  
  9         59  
10              
11              
12 9     9   18190 use Digest::HMAC_SHA1 'hmac_sha1_hex';
  9         17449  
  9         533  
13 9     9   610 use HTTP::Status qw(:constants);
  9         5005  
  9         3960  
14              
15 9     9   571 use Plack::Request;
  9         77297  
  9         262  
16 9     9   4311 use Plack::Response;
  9         11240  
  9         265  
17 9     9   67 use Plack::Util;
  9         22  
  9         438  
18 9         47 use Plack::Util::Accessor qw(
19             blocked
20             cookie_expiry_seconds
21             cookie_name
22             cookie_is_session_cookie
23             cookie_options
24             http_method_regex
25             contents_to_filter_regex
26             inject_form_input
27             logger
28             meta_tag
29             token_per_request
30             parameter_name
31             header_name
32             secret
33 9     9   58 );
  9         19  
34              
35             sub prepare_app {
36 88     88 1 13133 my $self = shift;
37              
38             # this needs a value if we aren't given one
39 88   50     220 $self->parameter_name( $self->parameter_name || 'xsrf_token' );
40              
41             # default to 1 so we inject hidden inputs to forms
42 88 50       1344 $self->inject_form_input(1) unless defined $self->inject_form_input;
43              
44             # match methods
45 88   33     771 $self->http_method_regex( $self->http_method_regex || qr{^post$}i );
46              
47             # match content types
48 88   33     1026 $self->contents_to_filter_regex(
49             $self->contents_to_filter_regex ||
50             qr{^(?: (?:text/html) | (?:application/xhtml(?:\+xml)?) )\b}ix,
51             );
52              
53             # store the cookie_name
54 88   50     1130 $self->cookie_name( $self->cookie_name || 'PSGI-XSRF-Token' );
55              
56             # cookie is session cookie
57 88   50     898 $self->cookie_is_session_cookie( $self->cookie_is_session_cookie || 0 );
58              
59             # extra optional options for the cookie
60 88   100     892 $self->cookie_options( $self->cookie_options || {} );
61              
62             # default to one token per session, not one per request
63 88 100       843 my $token_per_request = $self->token_per_request ? 1 : 0;
64             $self->token_per_request(
65             ref $self->token_per_request eq 'CODE'
66             ? $self->token_per_request
67 4     4   35 : sub { $token_per_request }
68 88 100       410 );
69              
70             # default to a cookie life of three hours
71 88   50     969 $self->cookie_expiry_seconds( $self->cookie_expiry_seconds || (3 * 60 * 60) );
72             }
73              
74              
75             sub detect_xsrf {
76 13     13 1 21 my $self = shift;
77 13         24 my $request = shift;
78 13         17 my $env = shift;
79              
80             # X- header takes precedence over form fields
81 13         22 my $val;
82 13 100       46 $val = $request->header( $self->header_name )
83             if (defined $self->header_name);
84             # fallback to the parameter value
85 13   100     657 $val ||= $request->parameters->{ $self->parameter_name };
86              
87             # it's not easy to decide if we're missing the X- value or the form
88             # value
89             # We can say for certain that if we don't have the header_name set
90             # it's a missing form parameter
91             # If it is set ... well, either could be missing
92 13 100 100     3626 if (!defined $val || !length $val) {
93             # no X- headers expected
94 7 100       18 return 'form field missing'
95             if not defined $self->header_name;
96              
97             # X- headers and form data allowed
98 1         17 return 'xsrf token missing';
99              
100             }
101              
102             # grab the cookie where we store the token
103 6         29 my $cookie_value = $request->cookies->{$self->cookie_name};
104              
105             # get the value we expect from the cookie
106 6 50       501 return 'cookie missing'
107             unless defined $cookie_value;
108              
109             # reject if the form value and the token don't match
110 6 100       21 return 'invalid token'
111             if $val ne $cookie_value;
112              
113 3 50       13 return 'invalid signature'
114             if $self->invalid_signature($val);
115              
116             # No XSRF detected
117 3         29 return;
118             }
119              
120             sub call {
121 40     40 1 355492 my $self = shift;
122 40         71 my $env = shift;
123              
124             # cache the logger
125       23     $self->logger($env->{'psgix.logger'} || sub { })
126 40 100 50     148 unless defined $self->logger;
127              
128             # we'll need the Plack::Request for this request
129 40         724 my $request = Plack::Request->new($env);
130              
131             # deal with form posts
132 40 100       461 if ($request->method =~ $self->http_method_regex) {
133 13         215 $self->log(info => 'form submitted');
134              
135 13         44 my $msg = $self->detect_xsrf($request, $env);
136 13 100       86 return $self->xsrf_detected({ env => $env, msg => $msg })
137             if defined $msg;
138             }
139              
140 30         505 return $self->filter_response($request, $env);
141             }
142              
143              
144             sub should_be_filtered {
145 30     30 1 77 my ($self, $request, $env, $res) = @_;
146              
147 30         130 my $headers = Plack::Util::headers($res->[1]);
148 30   50     953 my $ct = $headers->get('Content-Type') || '';
149 30         1095 return !! ($ct =~ $self->contents_to_filter_regex);
150             }
151              
152              
153             sub generate_token {
154 25     25 1 72 my ($self, $request, $env, $res) = @_;
155              
156 25         123 my $token = $request->cookies->{$self->cookie_name};
157              
158 25 100 100     1280 return $token if $token && !$self->token_per_request->( $self, $request, $env );
159              
160 21         349 my $data = rand() . $$ . {} . time;
161 21         159 my $key = "@INC";
162 21         88 $token = hmac_sha1_hex($data, $key);
163              
164 21 50       847 if (defined $self->secret) {
165 0         0 my $sig = hmac_sha1_hex($token, $self->secret);
166 0         0 $token .= "--$sig";
167             }
168              
169 21         152 return $token;
170             }
171              
172              
173             sub cookie_handler {
174 25     25 1 74 my ($self, $request, $env, $res, $token) = @_;
175              
176 25         43 my %cookie_expires;
177 25 50       83 unless ( $self->cookie_is_session_cookie ) {
178 25         157 $cookie_expires{expires} = time + $self->cookie_expiry_seconds;
179             }
180              
181             # we need to add our cookie
182             $self->_set_cookie(
183 25         209 $token,
184             $res,
185             path => '/',
186             %cookie_expires,
187             );
188              
189 25         70 return;
190             }
191              
192              
193             sub filter_response_html {
194 25     25 1 77 my ($self, $request, $env, $res, $token) = @_;
195              
196             # Do not load these unless HTML filter is used
197 25         4576 require HTML::Parser;
198 25         45584 require HTML::Escape;
199 25         5060 import HTML::Escape qw(escape_html);
200              
201             # escape token (someone might have tampered with the cookie)
202 25         157 $token = escape_html($token);
203              
204             # let's inject our field+token into the form
205 25         51 my @out;
206 25         151 my $http_host = $request->uri->host;
207 25         8271 my $parameter_name = $self->parameter_name;
208              
209 25         587 my $p = HTML::Parser->new( api_version => 3 );
210              
211             $p->handler(default => [\@out , '@{text}']),
212              
213             # we need *all* tags, otherwise we end up with gibberish as the final
214             # page output
215             # i.e. unless there's a better way, we *can not* do
216             # $p->report_tags(qw/head form/);
217              
218             # inject our xSRF information
219             $p->handler(
220             start => sub {
221 175     175   383 my($tag, $attr, $text) = @_;
222             # we never want to throw anything away
223 175         294 push @out, $text;
224              
225             # for easier comparison
226 175         272 $tag = lc($tag);
227              
228             # If we found the head tag and we want to add a <meta> tag
229 175 100 100     470 if( $tag eq 'head' && $self->meta_tag) {
230             # Put the csrftoken in a <meta> element in <head>
231             # So that you can get the token in javascript in your
232             # App to set in X-CSRF-Token header for all your AJAX
233             # Requests
234 4         41 push @out,
235             sprintf(
236             q{<meta name="%s" content="%s"/>},
237             $self->meta_tag,
238             $token
239             );
240             }
241              
242             # If tag isn't 'form' and method isn't matched, we dont care
243             return unless
244             defined $tag
245             && defined $attr->{'method'}
246             && $tag eq 'form'
247 175 50 66     1256 && $attr->{'method'} =~ $self->http_method_regex;
      66        
      66        
248              
249 25 0 33     492 if(
      33        
      33        
      33        
250             !(
251             defined $attr
252             and
253             exists $attr->{'action'}
254             and
255             $attr->{'action'} =~ m{^https?://([^/:]+)[/:]}
256             and
257             defined $http_host
258             and
259             $1 ne $http_host
260             )
261             ) {
262 25         181 push @out,
263             sprintf(
264             '<input type="hidden" name="%s" value="%s" />',
265             $parameter_name,
266             $token
267             );
268             }
269              
270             # TODO: determine xhtml or html?
271 25         132 return;
272             },
273 25         1044 "tagname, attr, text",
274             );
275              
276             # we never want to throw anything away
277             $p->handler(
278             default => sub {
279 425     425   907 my($tag, $attr, $text) = @_;
280 425         1739 push @out, $text;
281             },
282 25         154 "tagname, attr, text",
283             );
284              
285 25         56 my $done;
286             return sub {
287 50 50   50   1119 return if $done;
288              
289 50 100       135 if(defined(my $chunk = shift)) {
290 25         169 $p->parse($chunk);
291             }
292             else {
293 25         148 $p->eof;
294 25         70 $done++;
295             }
296 50         306 join '', splice @out;
297             }
298 25         151 }
299              
300              
301             sub filter_response {
302 30     30 1 79 my ($self, $request, $env) = @_;
303              
304             return Plack::Util::response_cb($self->app->($env), sub {
305 30     30   5821 my $res = shift;
306              
307 30 100       110 return $res unless $self->should_be_filtered($request, $env, $res);
308              
309 25         623 my $token = $self->generate_token($request, $env, $res);
310              
311 25         130 $self->cookie_handler($request, $env, $res, $token);
312              
313 25 50       78 return $res unless $self->inject_form_input;
314              
315 25         175 return $self->filter_response_html($request, $env, $res, $token);
316 30         131 });
317             }
318              
319              
320             sub invalid_signature {
321 3     3 1 8 my ($self, $value) = @_;
322              
323             # we dont use signed cookies
324 3 50       17 return 0 if !defined $self->secret;
325              
326             # cookie isn't signed
327 0         0 my ($token, $signature) = split /--/, $value;
328 0 0 0     0 return 1 if !defined $signature || $signature eq '';
329              
330             # signature doesn't validate
331 0         0 return hmac_sha1_hex($token, $self->secret) ne $signature;
332             }
333              
334              
335             sub xsrf_detected {
336 10     10 1 20 my $self = shift;
337 10         17 my $args = shift;
338 10         17 my $env = $args->{env};
339             my $msg = $args->{msg}
340             ? sprintf('XSRF detected [%s]', $args->{msg})
341 10 50       53 : 'XSRF detected';
342              
343 10 100       30 if (my $app_for_blocked = $self->blocked) {
344 1         9 $self->log(info => "$msg, invoking `blocked` coderef");
345 1         5 return $app_for_blocked->($env, $msg, app => $self->app);
346             }
347              
348 9         63 $self->log(error => "$msg, returning HTTP_FORBIDDEN");
349              
350             return [
351 9         141 HTTP_FORBIDDEN,
352             [ 'Content-Type' => 'text/plain', 'Content-Length' => length($msg) ],
353             [ $msg ]
354             ];
355             }
356              
357              
358             sub log {
359 23     23 1 54 my ($self, $level, $msg) = @_;
360 23         111 $self->logger->({ level => $level, message => "XSRFBlock: $msg" });
361             }
362              
363             # taken from Plack::Session::State::Cookie
364             # there's a very good reason why we have to do the cookie setting this way ...
365             # I just can't explain it clearly right now
366             sub _set_cookie {
367 25     25   109 my($self, $id, $res, %options) = @_;
368              
369             # TODO: Do not use Plack::Response
370 25         168 my $response = Plack::Response->new(@$res);
371             $response->cookies->{ $self->cookie_name } = +{
372             value => $id,
373             %options,
374 25         2365 %{ $self->cookie_options },
  25         70  
375             };
376              
377 25         506 my $final_r = $response->finalize;
378 25         4886 $res->[1] = $final_r->[1]; # headers
379             }
380              
381             1;
382              
383              
384              
385             # ABSTRACT: Block XSRF Attacks with minimal changes to your app
386              
387             =pod
388              
389             =encoding UTF-8
390              
391             =head1 NAME
392              
393             Plack::Middleware::XSRFBlock - Block XSRF Attacks with minimal changes to your app
394              
395             =head1 VERSION
396              
397             version 0.0.19
398              
399             =head1 SYNOPSIS
400              
401             The simplest way to use the plugin is:
402              
403             use Plack::Builder;
404              
405             my $app = sub { ... };
406              
407             builder {
408             enable 'XSRFBlock';
409             $app;
410             }
411              
412             You may also over-ride any, or all of these values:
413              
414             builder {
415             enable 'XSRFBlock',
416             parameter_name => 'xsrf_token',
417             cookie_name => 'PSGI-XSRF-Token',
418             cookie_options => {},
419             cookie_expiry_seconds => (3 * 60 * 60),
420             token_per_request => 0,
421             meta_tag => undef,
422             inject_form_input => 1,
423             header_name => undef,
424             secret => undef,
425             http_method_regex => qr{^post$}i,
426             contents_to_filter_regex => qr{^(text/html|application/xhtml(?:\+xml)?)\b}i,
427             blocked => sub {
428             return [ $status, $headers, $body ]
429             },
430             ;
431             $app;
432             }
433              
434             =head1 DESCRIPTION
435              
436             This middleware blocks XSRF. You can use this middleware without any
437             modifications to your application.
438              
439             =head1 OPTIONS
440              
441             =over 4
442              
443             =item parameter_name (default: 'xsrf_token')
444              
445             The name assigned to the hidden form input containing the token.
446              
447             =item cookie_name (default: 'PSGI-XSRF-Token')
448              
449             The name of the cookie used to store the token value.
450              
451             =item cookie_expiry_seconds (default: 3*60*60)
452              
453             The expiration time in seconds of the XSRF token
454              
455             =item cookie_is_session_cookie (default: 0)
456              
457             If set to a true value, the XSRF token cookie will be set as a session cookie
458             and C<cookie_expiry_seconds> will be ignored.
459              
460             =item cookie_options (default: {})
461              
462             Extra cookie options to be set with the cookie. This is useful for things like
463             setting C<HttpOnly> to tell the browser to only send it with HTTP requests,
464             and C<Secure> on the cookie to force the cookie to only be sent on SSL requests.
465              
466             builder {
467             enable 'XSRFBlock', cookie_options => { secure => 1, httponly => 1 };
468             }
469              
470             =item token_per_request (default: 0)
471              
472             If this is true a new token is assigned for each request made (but see below).
473              
474             This may make your application more secure, but more susceptible to
475             double-submit issues.
476              
477             If this is a coderef, the coderef will be evaluated with the following arguments:
478              
479             =item http_method_regex (default: qr{^post$}i)
480              
481             Which HTTP methods to check. Can be useful to also handle PUT, DELETE,
482             PATCH, and the like.
483              
484             =item contents_to_filter_regex default: qr{^(text/html|application/xhtml(?:\+xml)?)\b}i)
485              
486             Only modify <form> elements in responses whose content type matches this regex
487              
488             =over
489              
490             =item * The middleware object itself,
491              
492             =item * The request,
493              
494             =item * The environment
495              
496             =back
497              
498             If the result of the evaluation is a true value, a new token will be assigned.
499             This allows fine-grained control, for example to avoid assigning new tokens when
500             incidental requests are made (e.g. on-page ajax requests).
501              
502             =item meta_tag (default: undef)
503              
504             If this is set, use the value as the name of the meta tag to add to the head
505             section of output pages.
506              
507             This is useful when you are using javascript that requires access to the token
508             value for making AJAX requests.
509              
510             =item inject_form_input (default: 1)
511              
512             If this is unset, hidden inputs will not be injected into your forms, and no
513             HTML parsing will be done on the page responses.
514              
515             This can be useful if you only do AJAX requests, and can utilize headers
516             and/or cookies instead, and not need the extra overhead of processing
517             the HTML document every time.
518              
519             =item header_name (default: undef)
520              
521             If this is set, use the value as the name of the response heaer that the token
522             can be sent in. This is useful for non-browser based submissions; e.g.
523             Javascript AJAX requests.
524              
525             =item secret (default: undef)
526              
527             Signs the cookie with supplied secret (if set).
528              
529             =item blocked (default: undef)
530              
531             If this is set it should be a PSGI application that is returned instead of the
532             default HTTP_FORBIDDEN(403) and text/plain response.
533              
534             This could be useful if you'd like to perform some action that's more in
535             keeping with your application - e.g. return a styled error page.
536              
537             =back
538              
539             =head1 ERRORS
540              
541             The module emits various errors based on the cause of the XSRF detected. The
542             messages will be of the form C<XSRF detected [reason]>
543              
544             =over 4
545              
546             =item form field missing
547              
548             The request was submitted but there was no value submitted in the form field
549             specified by <C$self->parameter_name> [default: xsrf_token]
550              
551             =item xsrf token missing
552              
553             The application has been configured to accept an 'X-' header and no token
554             value was found in either the header or a suitable form field. [default: undef]
555              
556             =item cookie missing
557              
558             There is no cookie with the name specified by C<$self->cookie_name> [default:
559             PSGI-XSRF-Token]
560              
561             =item invalid token
562              
563             The cookie token and form value were both submitted correctly but the values
564             do not match.
565              
566             =item invalid signature
567              
568             The cookies signature is invalid, indicating it was tampered with on the way
569             to the browser.
570              
571             =back
572              
573             =head2 detect_xsrf($self, $request, $env)
574              
575             returns a message explaining the XSRF-related problem, or C<undef> if
576             there's no problem
577              
578             =head2 should_be_filtered($self, $request, $env, $res)
579              
580             returns true if the response should be filtered by this middleware
581             (currently, if its content-type matches C<contents_to_filter_regex>)
582              
583             =head2 generate_token($self, $request, $env, $res)
584              
585             Returns the token value to use for this response.
586              
587             If the cookie is already set, and we do not want a different token for
588             each request, returns the cookie's value.
589              
590             Otherwise, generates a new value based on some random data. If
591             C<secret> is set, the value is also signed.
592              
593             =head2 cookie_handler($self, $request, $env, $res, $token)
594              
595             sets the given token as a cookie in the response
596              
597             =head2 filter_response_html($self, $request, $env, $res, $token)
598              
599             Filters the response, injecting C<< <input> >> elements with the token
600             value into all forms whose method matches C<http_method_regex>.
601              
602             Streaming responses are still streaming after the filtering.
603              
604             =head2 filter_response($self, $request, $env)
605              
606             Calls the application, and (if the response L<< /C<should_be_filtered>
607             >>), it injects the token in the cookie and (if L<<
608             /C<inject_form_input> >>) the forms.
609              
610             =head2 invalid_signature($self, $value)
611              
612             Returns true if the value is not correctly signed. If we're not
613             signing tokens, this method always returns false.
614              
615             =head2 xsrf_detected($self, $args)
616              
617             Invoked when the XSRF is detected. Calls the L<< /C<blocked> >>
618             coderef if we have it, or returns a 403.
619              
620             The C<blocked> coderef is invoked like:
621              
622             $self->blocked->($env,$msg, app => $self->app);
623              
624             =over
625              
626             =item *
627              
628             the original request PSGI environment
629              
630             =item *
631              
632             the error message (from L<< /C<detect_xsrf> >>)
633              
634             =item *
635              
636             a hash, currently C<< app => $self->app >>, so you can call the
637             original application
638              
639             =back
640              
641             =head2 log($self, $level, $msg)
642              
643             log through the PSGI logger, if defined
644              
645             =head1 EXPLANATION
646              
647             This module is similar in nature and intention to
648             L<Plack::Middleware::CSRFBlock> but implements the xSRF prevention in a
649             different manner.
650              
651             The solution implemented in this module is based on a CodingHorror article -
652             L<Preventing CSRF and XSRF Attacks|http://www.codinghorror.com/blog/2008/10/preventing-csrf-and-xsrf-attacks.html>.
653              
654             The driving comment behind this implementation is from
655             L<the Felten and Zeller paper|https://www.eecs.berkeley.edu/~daw/teaching/cs261-f11/reading/csrf.pdf>:
656              
657             When a user visits a site, the site should generate a (cryptographically
658             strong) pseudorandom value and set it as a cookie on the user's machine.
659             The site should require every form submission to include this pseudorandom
660             value as a form value and also as a cookie value. When a POST request is
661             sent to the site, the request should only be considered valid if the form
662             value and the cookie value are the same. When an attacker submits a form
663             on behalf of a user, he can only modify the values of the form. An
664             attacker cannot read any data sent from the server or modify cookie
665             values, per the same-origin policy. This means that while an attacker can
666             send any value he wants with the form, he will be unable to modify or read
667             the value stored in the cookie. Since the cookie value and the form value
668             must be the same, the attacker will be unable to successfully submit a
669             form unless he is able to guess the pseudorandom value.
670              
671             =head2 What's wrong with Plack::Middleware::CSRFBlock?
672              
673             L<Plack::Middleware::CSRFBlock> is a great module.
674             It does a great job of preventing CSRF behaviour with minimal effort.
675              
676             However when we tried to use it uses the session to store information - which
677             works well most of the time but can cause issues with session timeouts or
678             removal (for any number of valid reasons) combined with logging (back) in to
679             the application in another tab (so as not to interfere with the current
680             screen/tab state).
681              
682             Trying to modify the existing module to provide the extra functionality and
683             behaviour we decided worked better for our use seemed too far reaching to try
684             to force into the existing module.
685              
686             =head2 FURTHER READING
687              
688             =over 4
689              
690             =item * Preventing CSRF and XSRF Attacks
691              
692             L<http://www.codinghorror.com/blog/2008/10/preventing-csrf-and-xsrf-attacks.html>
693              
694             =item * Preventing Cross Site Request Forgery (CSRF)
695              
696             L<https://www.golemtechnologies.com/articles/csrf>
697              
698             =item * Cross-Site Request Forgeries: Exploitation and Prevention [PDF]
699              
700             L<https://www.eecs.berkeley.edu/~daw/teaching/cs261-f11/reading/csrf.pdf>
701              
702             =item * Cross-Site Request Forgery (CSRF) Prevention Cheat Sheet
703              
704             L<https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet>
705              
706             =back
707              
708             =head2 SEE ALSO
709              
710             L<Plack::Middleware::CSRFBlock>,
711             L<Plack::Middleware>,
712             L<Plack>
713              
714             =begin markdown
715              
716             ## BUILD STATUS
717              
718             [![Build Status](https://travis-ci.org/chiselwright/plack-middleware-xsrfblock.svg?branch=master)](https://travis-ci.org/chiselwright/plack-middleware-xsrfblock)
719              
720             =end markdown
721              
722             =head1 AUTHOR
723              
724             Chisel <chisel@chizography.net>
725              
726             =head1 COPYRIGHT AND LICENSE
727              
728             This software is copyright (c) 2023 by Chisel Wright.
729              
730             This is free software; you can redistribute it and/or modify it under
731             the same terms as the Perl 5 programming language system itself.
732              
733             =head1 CONTRIBUTORS
734              
735             =for stopwords Andrey Khozov Ashley Pond V Chisel Daniel Perrett Gianni Ceccarelli Karen Etheridge Matthew Ryall Matthias Zeichmann Michael Kröll Sebastian Willert Sterling Hanenkamp William Wolf
736              
737             =over 4
738              
739             =item *
740              
741             Andrey Khozov <andrey@rydlab.ru>
742              
743             =item *
744              
745             Ashley Pond V <ashley.pond.v@gmail.com>
746              
747             =item *
748              
749             Chisel <chisel.wright@net-a-porter.com>
750              
751             =item *
752              
753             Daniel Perrett <dp13@sanger.ac.uk>
754              
755             =item *
756              
757             Gianni Ceccarelli <dakkar@thenautilus.net>
758              
759             =item *
760              
761             Gianni Ceccarelli <gianni.ceccarelli@broadbean.com>
762              
763             =item *
764              
765             Karen Etheridge <ether@cpan.org>
766              
767             =item *
768              
769             Matthew Ryall <matt.ryall@gmail.com>
770              
771             =item *
772              
773             Matthias Zeichmann <matthias.zeichmann@gmail.com>
774              
775             =item *
776              
777             Michael Kröll <michael.kroell@geizhals.at>
778              
779             =item *
780              
781             Sebastian Willert <willert@gmail.com>
782              
783             =item *
784              
785             Sterling Hanenkamp <sterling@ziprecruiter.com>
786              
787             =item *
788              
789             William Wolf <throughnothing@gmail.com>
790              
791             =back
792              
793             =cut
794              
795             __END__
796             # vim: ts=8 sts=4 et sw=4 sr sta