File Coverage

blib/lib/Mojolicious/Plugin/ClosedRedirect.pm
Criterion Covered Total %
statement 74 75 98.6
branch 25 30 83.3
condition 12 17 70.5
subroutine 9 9 100.0
pod 2 2 100.0
total 122 133 91.7


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