File Coverage

lib/Plack/Middleware/XSRFBlock.pm
Criterion Covered Total %
statement 136 141 96.4
branch 37 52 71.1
condition 31 56 55.3
subroutine 27 27 100.0
pod 11 11 100.0
total 242 287 84.3


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