File Coverage

blib/lib/Mojolicious/Plugin/ClosedRedirect.pm
Criterion Covered Total %
statement 75 76 98.6
branch 22 28 78.5
condition 9 14 64.2
subroutine 9 9 100.0
pod 2 2 100.0
total 117 129 90.7


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::ClosedRedirect;
2 6     6   49569 use Mojo::Base 'Mojolicious::Plugin';
  6         93457  
  6         42  
3 6     6   1883 use Mojo::ByteStream 'b';
  6         2345  
  6         266  
4 6     6   32 use Mojo::Util qw/secure_compare url_unescape quote/;
  6         8  
  6         5445  
5              
6             our $VERSION = '0.14';
7              
8             # TODO: Support domain whitelisting, like
9             # https://github.com/sdsdkkk/safe_redirect
10             # TODO: Accept same origin URLs.
11             # TODO: Probably enforce full URLs to handle things like:
12             # https://www.redmine.org/issues/19577
13              
14             # Register plugin
15             sub register {
16 7     7 1 10956 my ($plugin, $app, $param) = @_;
17              
18 7   50     43 $param ||= {};
19              
20             # Load parameter from Config file
21 7 50       50 if (my $config_param = $app->config('ClosedRedirect')) {
22 0         0 $param = { %$param, %$config_param };
23             };
24              
25             # Set secrets
26 7         116 my $secrets;
27 7 100 66     40 if ($param->{secrets} && ref $param->{secrets} eq 'ARRAY') {
28 2         5 $plugin->secrets($param->{secrets});
29 2         4 $secrets = $param->{secrets};
30             }
31              
32             # Get secrets from application
33             else {
34 5         14 $secrets = $app->secrets;
35             };
36              
37 7         48 my ($log, $plugins) = ($app->log, $app->plugins);
38              
39             # Establish 'close_redirect_to' helper
40             $app->helper(
41             close_redirect_to => sub {
42 15     15   28900 my $c = shift;
43              
44 15         46 my $url = $c->url_for(@_);
45              
46             # Delete possible 'crto' parameter
47 15         3257 $url->query->remove('crto');
48              
49             # Canonicalize
50 15         713 $url->path->canonicalize;
51              
52             # Get the first plugin secret or the first application secret
53 15         578 my $secret = $secrets->[0];
54              
55             # Calculate check
56 15         51 my $url_check =
57             b($url->to_string)
58             ->url_unescape
59             ->hmac_sha1_sum($secret);
60              
61             # Append check parameter to url
62 15         4268 $url->query({ crto => $url_check });
63 15         483 return $url->to_string;
64             }
65 7         869 );
66              
67             # Redirect to relative URL
68             $app->helper(
69             relative_redirect_to => sub {
70 1     1   83 my $c = shift;
71              
72             # Get the base path of the request URL
73 1         3 my $path = $c->req->url->base->path->canonicalize;
74              
75             # Get URL
76 1         76 my $redirect = $c->url_for(@_);
77              
78             # In case path is set, remove path prefix
79 1 50       197 if ($path) {
80 1         7 my $redirect_parts = $redirect->path->parts;
81 1         11 foreach (@{$path->parts}) {
  1         3  
82 2 50 33     16 if ($redirect_parts->[0] && ($_ eq $redirect_parts->[0])) {
83 2         4 shift @$redirect_parts;
84             };
85             };
86             };
87              
88             # Don't override 3xx status
89 1         14 my $res = $c->res;
90 1         24 $res->headers->location($redirect);
91 1 50       17 return $c->rendered($res->is_redirect ? () : 302);
92             }
93 7         211 );
94              
95             # Add validation check
96             # Alternatively make this a filter instead
97             $app->validator->add_check(
98             closed_redirect => sub {
99 27     27   248706 my ($v, $name, $return_url, $method) = @_;
100 27   100     103 $method //= '';
101              
102             # No URL given
103             # This is not judged as an Open Redirect attack
104 27 50       62 return 'Redirect is missing' unless $return_url;
105              
106 27         68 my ($err, $url);
107              
108             # No array allowed
109 27 100       62 if (ref $v->output->{$name} eq 'ARRAY') {
110 1         7 $err = 'Redirect is defined multiple times';
111             }
112              
113             # Parameter is fine
114             else {
115              
116             # Check for local paths
117 26 100       158 if ($method ne 'signed') {
118              
119             # That's fine
120 10 100       28 if (_local_path($return_url)) {
121             # Get url
122 6         22 $url = Mojo::URL->new($return_url);
123              
124             # Remove parameter if existent
125 6         332 $url->query->remove('crto');
126              
127             # Rewrite parameter
128 6         322 $v->output->{$name} = $url->to_string;
129              
130 6         756 return;
131             };
132             };
133              
134             # Get url
135 20         67 $url = Mojo::URL->new($return_url);
136              
137             # local_path not valid
138             # Support signing
139 20 100       2013 unless ($method eq 'local') {
140              
141             # Get 'crto' parameter
142 18         72 my $check = $url->query->param('crto');
143              
144             # No check parameter available
145 18 100       1474 if ($check) {
146              
147             # Remove parameter
148 15         48 $url->query->remove('crto');
149              
150 15         281 my $url_check;
151              
152             # Check all secrets
153 15         38 foreach (@$secrets) {
154              
155             # Calculate check
156 17         276 $url_check =
157             b($url->to_string)->
158             url_unescape->
159             hmac_sha1_sum($_);
160              
161             # Check if signed url is valid
162 17 100       2569 if (secure_compare($url_check, $check)) {
163              
164             # TODO: Remove authorization stuff!
165              
166             # Rewrite parameter
167 11         1307 $v->output->{$name} = $url->to_string;
168 11         1331 return;
169             };
170             };
171             };
172             };
173             };
174              
175 10   100     234 $err //= 'Redirect is invalid';
176              
177             # Emit hook
178 10         42 $plugins->emit_hook(
179             on_open_redirect_attack => ( $name, $return_url, $err )
180             );
181              
182             # Warn in log
183             # Prevents log-injection attack
184 10         247 $log->warn(
185             "Open Redirect Attack - $err: URL for " .
186             quote($name) . ' is ' . quote($return_url)
187             );
188              
189 10         398 return $err;
190             }
191 7         98 );
192             };
193              
194              
195             # secrets attribute
196             sub secrets {
197 2     2 1 4 my $self = shift;
198 2 50       5 if (@_ > 0) {
199 2         7 $self->{secrets} = shift;
200             };
201 2   50     6 return $self->{secrets} // [];
202             };
203              
204              
205             # Check for local Path
206             # Based on http://www.asp.net/mvc/overview/security/preventing-open-redirection-attacks
207             sub _local_path {
208 31     31   142 my $url = url_unescape $_[0];
209 31 100       295 return 1 if $url =~ m!^(?:/(?:[^\/\\]|$)|~\/.)!;
210 22         62 return;
211             };
212              
213              
214             1;
215              
216              
217             __END__