File Coverage

blib/lib/Mojolicious/Plugin/SecureCORS.pm
Criterion Covered Total %
statement 97 112 86.6
branch 36 54 66.6
condition 7 11 63.6
subroutine 13 15 86.6
pod 1 1 100.0
total 154 193 79.7


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::SecureCORS;
2              
3 3     3   138523 use Mojo::Base 'Mojolicious::Plugin';
  3         385245  
  3         26  
4 3     3   2132 use Carp;
  3         6  
  3         219  
5              
6             our $VERSION = 'v2.0.5';
7              
8 3     3   1747 use List::MoreUtils qw( any none );
  3         37881  
  3         22  
9              
10 3     3   3409 use constant DEFAULT_MAX_AGE => 1800;
  3         7  
  3         5929  
11              
12              
13             sub register {
14 1     1 1 47 my ($self, $app, $conf) = @_;
15 1 50       4 if (!exists $conf->{max_age}) {
16 1         3 $conf->{max_age} = DEFAULT_MAX_AGE;
17             }
18              
19 1         7 my $root = $app->routes;
20              
21             $root->add_shortcut(under_strict_cors => sub {
22 1     1   441 my ($r, @args) = @_;
23 1         7 return $r->under(@args)->to(cb => \&_strict);
24 1         15 });
25              
26             $root->add_shortcut(cors => sub {
27 1     1   2172 my ($r, @args) = @_;
28             return $r->any(@args)
29             ->methods('OPTIONS')
30             ->requires(
31             headers => {
32             'Origin' => qr/\S/ms,
33             'Access-Control-Request-Method' => qr/\S/ms,
34             },
35             )
36 1         4 ->to(cb => sub { _preflight($conf, @_) });
  8         70468  
37 1         122 });
38              
39 1         88 $app->hook(after_render => \&_request);
40              
41 1         25 return;
42             }
43              
44             sub _strict {
45 4     4   34741 my ($c) = @_;
46              
47 4 100       13 if (!defined $c->req->headers->origin) {
48 2         54 return 1; # Not a CORS request, pass
49             }
50              
51 2         53 my $r = $c->match->endpoint;
52 2         16 while ($r) {
53 4 100       16 if ($r->to->{'cors.origin'}) {
54 1         22 return 1; # Endpoint configured for CORS, pass
55             }
56 3         36 $r = $r->parent;
57             }
58             # Endpoint not configured for CORS, block
59 1         9 $c->render(status => 403, text => 'CORS Forbidden');
60 1         250 return;
61             }
62              
63             sub _preflight {
64 8     8   18 my ($conf, $c) = @_;
65              
66 8         26 my $method = $c->req->headers->header('Access-Control-Request-Method');
67 8         194 my $match;
68             # use options defined on this route, if available
69 8 50       25 if ($c->match->endpoint->to->{'cors.origin'}) {
70 0         0 $match = $c->match;
71 0         0 my $opt_methods = $match->endpoint->to->{'cors.methods'};
72 0 0       0 if ($opt_methods) {
73 0         0 my %good_methods = map {lc $_ => 1} split /,\s*/ms, $opt_methods;
  0         0  
74 0 0       0 if (!$good_methods{lc $method}) {
75 0         0 return $c->render(status => 204, data => q{}); # Endpoint not found, ignore
76             }
77             }
78             }
79             # otherwise try to find route for actual request and use it options
80             else {
81 8         155 $match = Mojolicious::Routes::Match->new(root => $c->app->routes);
82 8         102 $match->find($c, {
83             method => $method,
84             path => $c->req->url->path,
85             });
86 8 100       6885 if (!$match->endpoint) {
87 4         28 return $c->render(status => 204, data => q{}); # Endpoint not found, ignore
88             }
89             }
90              
91 4         23 my %opt = _get_opt($match->endpoint);
92              
93 4 50       19 if (!$opt{origin}) {
94 0         0 return $c->render(status => 204, data => q{}); # Endpoint not configured for CORS, ignore
95             }
96              
97 4         20 my $h = $c->res->headers;
98 4         77 $h->append(Vary => 'Origin');
99              
100 4         136 my $origin = $c->req->headers->origin;
101 4 50       102 if (ref $opt{origin} eq 'Regexp') {
102 4 100       34 if ($origin !~ /$opt{origin}/ms) {
103 1         6 return $c->render(status => 204, data => q{}); # Bad Origin:
104             }
105             } else {
106 0 0 0 0   0 if (none {$_ eq q{*} || $_ eq $origin} split q{ }, $opt{origin} // q{}) {
  0 0       0  
107 0         0 return $c->render(status => 204, data => q{}); # Bad Origin:
108             }
109             }
110              
111 3         11 my $headers = $c->req->headers->header('Access-Control-Request-Headers');
112 3   100     76 my @want_headers = map {lc} split /,\s*/ms, $headers // q{};
  2         8  
113 3 50       12 if (ref $opt{headers} eq 'Regexp') {
114 0 0   0   0 if (any {!/$opt{headers}/ms} @want_headers) {
  0         0  
115 0         0 return $c->render(status => 204, data => q{}); # Bad Access-Control-Request-Headers:
116             }
117             } else {
118 3   50     13 my %good_headers = map {lc $_ => 1} split /,\s*/ms, $opt{headers} // q{};
  3         14  
119 3 100   2   26 if (any {!exists $good_headers{$_}} @want_headers) {
  2         7  
120 1         6 return $c->render(status => 204, data => q{}); # Bad Access-Control-Request-Headers:
121             }
122             }
123              
124 2         13 $h->header('Access-Control-Allow-Origin' => $origin);
125 2         54 $h->header('Access-Control-Allow-Methods' => $method);
126 2 100       52 if (defined $headers) {
127 1         5 $h->header('Access-Control-Allow-Headers' => $headers);
128             }
129 2 50       24 if ($opt{credentials}) {
130 2         6 $h->header('Access-Control-Allow-Credentials' => 'true');
131             }
132 2 50       49 if (defined $conf->{max_age}) {
133 2         8 $h->header('Access-Control-Max-Age' => $conf->{max_age});
134             }
135 2         47 return $c->render(status => 204, data => q{});
136             }
137              
138             sub _request {
139 24     24   211070 my ($c, $output, $format) = @_;
140              
141 24         80 my %opt = _get_opt($c->match->endpoint);
142              
143 24 100       71 if (!$opt{origin}) {
144 16         49 return; # Endpoint not configured for CORS, ignore
145             }
146              
147 8         30 my $h = $c->res->headers;
148 8         155 $h->append(Vary => 'Origin');
149              
150 8         274 my $origin = $c->req->headers->origin;
151 8 100       187 if (!defined $origin) {
152 2         8 return; # Not a CORS
153             }
154              
155 6 100       25 if (ref $opt{origin} eq 'Regexp') {
156 2 100       15 if ($origin !~ /$opt{origin}/ms) {
157 1         5 return; # Bad Origin:
158             }
159             } else {
160 4 100 50 6   40 if (none {$_ eq q{*} || $_ eq $origin} split q{ }, $opt{origin} // q{}) {
  6 100       33  
161 1         6 return; # Bad Origin:
162             }
163             }
164              
165 4         23 $h->header('Access-Control-Allow-Origin' => $origin);
166 4 100       92 if ($opt{credentials}) {
167 1         3 $h->header('Access-Control-Allow-Credentials' => 'true');
168             }
169 4 50       31 if ($opt{expose}) {
170 0         0 $h->header('Access-Control-Expose-Headers' => $opt{expose});
171             }
172 4         15 return;
173             }
174              
175             sub _get_opt {
176 28     28   191 my ($r) = @_;
177 28         47 my %opt;
178 28         81 while ($r) {
179 130         472 for my $name (qw( origin credentials expose headers )) {
180 520 100 100     4033 if (!exists $opt{$name} && exists $r->to->{"cors.$name"}) {
181 50         629 $opt{$name} = $r->to->{"cors.$name"};
182             }
183             }
184 130         1309 $r = $r->parent;
185             }
186 28         231 return %opt;
187             }
188              
189              
190             1; # Magic true value required at end of module
191             __END__