package URL::Signature; use warnings; use strict; use URI (); use URI::QueryParam (); use MIME::Base64 3.11 (); use Digest::HMAC (); use Carp (); use Class::Load (); use Params::Util qw( _STRING _POSINT _NONNEGINT _CLASS ); our $VERSION = '0.03'; sub new { my ($class, %attrs) = @_; Carp::croak('you must specify a secret key!') unless exists $attrs{'key'} and defined _STRING($attrs{'key'}); # Digest::SHA defaults to SHA-1 $attrs{'digest'} ||= 'Digest::SHA'; Carp::croak('digest must be a valid Perl class') unless defined _CLASS($attrs{'digest'}) and Class::Load::load_class($attrs{'digest'}); $attrs{'length'} ||= 28; Carp::croak('length should be a positive integer') unless defined _POSINT($attrs{'length'}); $attrs{'format'} ||= 'path'; my $child_class = 'URL::Signature::' . ucfirst $attrs{'format'}; Carp::croak(qq[invalid format '$attrs{format}']) unless defined _STRING($attrs{'format'}) and _CLASS($child_class); $attrs{'hmac'} = Digest::HMAC->new( $attrs{'key'}, $attrs{'digest'} ); my ($loaded, $error) = Class::Load::try_load_class( $child_class ); Carp::croak(qq[error trying to load format class '$child_class': $error]) unless $loaded; my $self = bless \%attrs, $child_class; $self->BUILD; return $self; } sub code_for_uri { my ($self, $uri) = @_; # ensure query parameters are sorted before encoding. my %params = $uri->query_form; $uri->query_form( map { $_ => $params{$_} } sort keys %params ); my $code = MIME::Base64::encode_base64url( $self->{hmac}->reset->add( $uri->as_string )->digest ); # truncate the digest, if necessary $code = substr $code, 0, $self->{'length'}; return $code; } sub sign { my ($self, $path) = @_; my $uri = URI->new( $path ); my $code = $self->code_for_uri( $uri ); return $self->append( $uri, $code ); } sub validate { my ($self, $path, $old_code) = @_; my $uri = URI->new( $path ); return $old_code eq $self->code_for_uri( $uri ) if $old_code; my ($code, $new_uri) = $self->extract( $uri ); return if not $code or not $uri or $code ne $self->code_for_uri( $new_uri ); return $new_uri; } # let our subclasses implement those sub BUILD {} sub extract {} sub append {} 42; __END__ =head1 NAME URL::Signature - Tamper-proof URLs with Signed authentication =head1 SYNOPSIS use URL::Signature; my $obj = URL::Signature->new( key => 'My secret key' ); # code above is the same as: my $obj = URL::Signature->new( key => 'My secret key', digest => 'Digest::SHA' length => 28, format => 'path', as => 1, # where in the uri path should we # look/place the signature. ); # get a URI object with the HMAC signature attached to it my $url = $obj->sign( '/path/to/somewhere?data=stuff' ); # if path is valid, get a URI object without the signature in it my $path = 'www.example.com/1b23094726520/some/path?data=value&other=extra'; my $validated = $obj->validate($path); Want to put your signatures in variables instead of the path? No problem! my $obj = URL::Signature->new( key => 'My secret key', format => 'query', as => 'foo', # variable name for us to # look/place the signature ); my $path = 'www.example.com/some/path?data=value&foo=1b23094726520&other=extra'; my $validated = $obj->validate($path); my $url = $obj->sign( '/path/to/somewhere?data=stuff' ); You can also do the mangling yourself and just check Check below for some examples on how to integrate the integrity check to some L. =head1 DESCRIPTION This module is a simple wrapper around L and . It is intended to make it simple to do integrity checks on URLs (and other URIs as well). =head2 URL Tampering? Sometimes you want to provide dynamic resources in your server based on path or query parameters. An image server, for instance, might want to provide different sizes and effects for images like so: http://myserver/images/150x150/flipped/perl.png A malicious user might take advantage of that to try and traverse through options or even L your application by forcing it to do tons of unnecessary processing and filesystem operations. One way to prevent this is to sign your URLs with HMAC and a secret key. In this approach, you authenticate your URL and append the resulting code to it. The above URL could look like this: http://myserver/images/041da974ac0390b7340/150x150/flipped/perl.png or http://myserver/images/150x150/flipped/perl.png?k=041da974ac0390b7340 This way, whenever your server receives a request, it can check the URL to see if the provided code matches the rest of the path. If a malicious user tries to tamper with the URL, the provided code will be a mismatch to the tampered path and you'll be able to catch it early on. It is worth noticing that, when in C<'query'> mode, the B. That means the following URIs are all considered valid (for the same given secret key): foo/bar?a=1&b=2&k=SOME_KEY foo/bar?a=1&k=SOME_KEY&b=2 foo/bar?b=2&k=SOME_KEY&a=1 foo/bar?b=2&a=1&k=SOME_KEY foo/bar?k=SOME_KEY&a=1&b=2 foo/var?k=SOME_KEY&b=2&a=1 =head1 METHODS =head2 new( %attributes ) Instatiates a new object. You can set the following properties: =over 4 =item * B - (B) A string containing the secret key used to generate and validate the URIs. As a security feature, this attribute contains no default value and is mandatory. Typically, your application will fetch the secret key from a configuration file of some sort. =item * B - The size of the resulting code string to be appended in your URIs. This needs to be a positive integer, and defaults to B<28>. Note that, the smaller the string, the easier it is for a malicious user to brute-force it. =item * B - This module provides two different formats for URL signing: 'I' and 'I'. When set to 'path', the authentication code will be injected into (and extracted from) one of the URI's segment. When set to 'query', it will be injected/extracted as a query parameter. Default is B. =item * B - When the format is 'I', this option will specify the segment's position in which to inject/extract the authentication code. If the format is set to 'path', this option defaults to B<1>. When the format is 'I', this option specifies the query parameter's name, and defaults to 'B'. Other format providers might specify different defaults, so please check their documentation for details. =item * B - The name of the module handling the message digest algorithm to be used. This is typically one of the C modules, and it must comply with the 'Digest' interface on CPAN. Defaults to L, which uses the SHA-1 algorithm by default. =back =head2 sign( $url_string ) Receives a string containing the URL to be signed. Returns a L object with the original URL modified to contain the authentication code. =head2 validate( $url_string ) Receives a string containing the URL to be validated. Returns false if the URL's auth code is not a match, otherwise returns an L object containing the original URL minus the authentication code. =head2 Convenience Methods Aside from C and C, there are a few other methods you may find useful: =head3 code_for_uri( $uri_object ) Receives a L object and returns a string containing the authentication code necessary for that object. =head3 extract( $uri_object ) my ($code, $new_uri) = $obj->extract( $original_uri ); Receives a L object and returns two elements: =over 4 =item * the extracted signature from the given URI =item * a new URI object just like the original minus the signature =back This method is implemented by the format subclasses themselves, so you're advised to referring to their documentation for specifics. =head3 append my $new_uri = $obj->append( $original_uri, $code ); Receives a L object and the authentication code to be inserted. Returns a new URI object with the auth code properly appended, according to the requested format. This method is implemented by the format subclasses themselves, so you're advised to referring to their documentation for specifics. =head1 EXAMPLES The code below demonstrates how to use URL::Signature in the real world. These are, of course, just snippets to show you possibilities, and are not meant to be rules or design patterns. Please refer to the framework's documentation and communities for best practices. =head2 Dancer Integration If you're using L, you can create a 'before' hook to check all your routes' signatures. This example uses the 'path' format. use Dancer; use URL::Signature; my $validator = URL::Signature->new( key => 'my-secret-key' ); hook 'before' => sub { my $uri = $validator->validate( request->uri ) or return send_error('forbidden', 403); # The following line is required only in 'path' mode, as # we need to remove the actual auth code from the request # path, otherwise Dancer won't find the real route. request->path( $uri->path ); }; get '/some/route' => sub { return 'Hi there, Miss Integrity!'; }; start; =head2 Plack Integration Most Perl web frameworks nowadays run on L. If you're working directly with L, you can use something like the example below to validate your URLs. This example uses the 'path' format. package Plack::App::MyApp; use parent qw(Plack::Component); use URL::Signature; my $validator = URL::Signature->new( key => 'my-secret-key' ); sub call { my ($self, $env) = @_; return [403, ['Content-Type' => 'text/plain', 'Content-Length' => 9], ['forbidden']] unless $validator->validate( $env->{REQUEST_URI} ); return [200, ['Content-Type' => 'text/plain', 'Content-Lengh' => 10], ['Validated!']]; } package main; my $app = Plack::App::MyApp->new->to_app; =head2 Catalyst integration L is arguably the most popular Perl MVC framework out there, and lets you chain your actions together for increased power and flexibility. In this example, we are using path validation in one of our controllers, and detaching to a 'default' action if the path's signature is invalid. sub signed :Chained('/') :PathPart('') :CaptureArgs(1) { my ($self, $c, $code) = @_; # get the key from your app's config file my $signer = URL::Signature->new( key => $c->config->{url_key} ); $c->detach('default') unless $signer->validate( $c->req->uri->path ); } Now we can make signed actions anywhere in the controller by simply making sure it is chained to our 'signed' sub: sub some_action :Chained('signed') { my ($self, $c) = @_; ... } =head2 Creating signed links to your actions When you need to create links to your signed routes, just use the sign() method as you normally would. If you're worried about your app's root namespace, just use your framework's C method. Below we created a 'C' function in Catalyst's stash as an example (though virtually all frameworks provide a C and a C helper): my $signer = URL::Signature->new( key => 'my-secret-key' ); $c->stash( signed_uri_for => sub { $signer->sign( $c->uri_for(@_)->path ) } ); # later on, in your code: my $link = $c->stash->{signed_uri_for}->( '/some/path' ); # or even better, from within your template: Click now! =head2 Getting the signature from HTTP headers Some argue that it's more elegant to pass the resource signature via HTTP headers, rather than altering the URL itself. URL::Signature also fits the bill, but in this case you'd use it in a slightly different way. Below is an example, using Catalyst: sub signed :Chained('/') { my ($self, $c) = @_; my $signer = URL::Signature->new( key => $c->config->{url_key} ); my $given_code = $c->req->header('X-Signature'); my $real_code = $signer->code_for_uri( $c->req->uri ); $c->detach('/') unless $given_code eq $real_code; } =head1 DIAGNOSTICS Messages from the constructor: =over 4 =item C<< digest must be a valid Perl class >> When setting the 'digest' attribute, it must be a string containing a valid module name, like C<'Digest::SHA'> or C<'Digest::MD5'>. =item C<< length should be a positive integer >> When setting the 'length' attribute, make sure its greater than zero. =item C<< format should be either 'path' or 'query' >> Self-explanatory :) =item C<< in 'path' format, 'as' needs to be a non-negative integer >> The 'path' mode means the auth code will be inserted as one of the URI segments. This means that, if you have a URI like: foo/bar/baz Then if 'as' is 0, it will change to: CODE/foo/bar/baz With 'as' set to 1 (the default for path mode), it changes to: foo/CODE/bar/baz And so on. As such, the value for 'as' should be 0 or greater. =item C<< in 'query' format, 'as' needs to be a valid string >> The 'query' mode means the auth code will be inserted as one of the URI variables (query parameters). This means that, if you have a URI like: foo/bar/baz Then if 'as' is set to 'k' (the default for query mode), it will change to: foo/bar/baz?k=CODE As such, the value for 'as' should be a valid string. =back Messages from C: =over 4 =item C<< variable '%s' (reserved for auth code) found in path >> When in query mode, the object will throw this exception when it tries to append the authentication code into the given URI, but finds that a variable with the same name already exists (the 'as' parameter in the constructor). =back =head1 EXTENDING When you specify a 'format' attribute, URL::Signature will try and load the proper subclass for you. For example, the 'path' format is implemented by L, and the 'query' format by L. Please follow the same convention for your custom formatters so others can use it via the URL::Signature interface. If you wish do create a new format for URL::Signature, you'll need to implement at least C and C. If you wish to mangle with constructor attributes, please do so in the BUILD(), as you would with Moose classes: =head2 BUILD URL::Signature will call this method on the subclass after the object is instantiated, just like Moose does. The only argument is $self, with attributes properly set into place in the internal hash for you to check. Feel free to skim through the bundled URL::Signature formatters and use them as a base to develop your own. =head1 CONFIGURATION AND ENVIRONMENT URL::Signature requires no configuration files or environment variables. =head1 BUGS AND LIMITATIONS Please report any bugs or feature requests to C, or through the web interface at L. =head1 SEE ALSO =over 4 =item * L =item * L =item * L =back =head1 AUTHOR Breno G. de Oliveira C<< >> =head1 LICENCE AND COPYRIGHT Copyright (c) 2013, Breno G. de Oliveira C<< >>. All rights reserved. This module is free software; you can redistribute it and/or modify it under the same terms as Perl itself. See L. =head1 DISCLAIMER OF WARRANTY BECAUSE THIS SOFTWARE IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE SOFTWARE, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE SOFTWARE "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE SOFTWARE IS WITH YOU. SHOULD THE SOFTWARE PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR, OR CORRECTION. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE SOFTWARE AS PERMITTED BY THE ABOVE LICENCE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE SOFTWARE (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE SOFTWARE TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. =cut