File Coverage

blib/lib/Mojolicious/Plugin/SecureCORS.pm
Criterion Covered Total %
statement 97 112 86.6
branch 36 54 66.6
condition 5 5 100.0
subroutine 13 15 86.6
pod 1 1 100.0
total 152 187 81.2


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::SecureCORS;
2              
3 3     3   142847 use Mojo::Base 'Mojolicious::Plugin';
  3         390436  
  3         34  
4 3     3   2472 use Carp;
  3         7  
  3         638  
5              
6             our $VERSION = 'v2.0.3';
7              
8 3     3   1896 use List::MoreUtils qw( any none );
  3         40571  
  3         21  
9              
10 3     3   3663 use constant DEFAULT_MAX_AGE => 1800;
  3         8  
  3         6209  
11              
12              
13             sub register {
14 1     1 1 45 my ($self, $app, $conf) = @_;
15 1 50       4 if (!exists $conf->{max_age}) {
16 1         4 $conf->{max_age} = DEFAULT_MAX_AGE;
17             }
18              
19 1         6 my $root = $app->routes;
20              
21             $root->add_shortcut(under_strict_cors => sub {
22 1     1   536 my ($r, @args) = @_;
23 1         6 return $r->under(@args)->to(cb => \&_strict);
24 1         14 });
25              
26             $root->add_shortcut(cors => sub {
27 1     1   1824 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         71886  
37 1         104 });
38              
39 1         55 $app->hook(after_render => \&_request);
40              
41 1         23 return;
42             }
43              
44             sub _strict {
45 4     4   35410 my ($c) = @_;
46              
47 4 100       15 if (!defined $c->req->headers->origin) {
48 2         65 return 1; # Not a CORS request, pass
49             }
50              
51 2         58 my $r = $c->match->endpoint;
52 2         17 while ($r) {
53 4 100       17 if ($r->to->{'cors.origin'}) {
54 1         18 return 1; # Endpoint configured for CORS, pass
55             }
56 3         37 $r = $r->parent;
57             }
58             # Endpoint not configured for CORS, block
59 1         11 $c->render(status => 403, text => 'CORS Forbidden');
60 1         251 return;
61             }
62              
63             sub _preflight {
64 8     8   18 my ($conf, $c) = @_;
65              
66 8         27 my $method = $c->req->headers->header('Access-Control-Request-Method');
67 8         206 my $match;
68             # use options defined on this route, if available
69 8 50       23 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         161 $match = Mojolicious::Routes::Match->new(root => $c->app->routes);
82 8         109 $match->find($c, {
83             method => $method,
84             path => $c->req->url->path,
85             });
86 8 100       6749 if (!$match->endpoint) {
87 4         40 return $c->render(status => 204, data => q{}); # Endpoint not found, ignore
88             }
89             }
90              
91 4         31 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         15 my $h = $c->res->headers;
98 4         84 $h->append(Vary => 'Origin');
99              
100 4         153 my $origin = $c->req->headers->origin;
101 4 50       109 if (ref $opt{origin} eq 'Regexp') {
102 4 100       39 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 if (none {$_ eq q{*} || $_ eq $origin} split q{ }, $opt{origin}) {
  0 0       0  
107 0         0 return $c->render(status => 204, data => q{}); # Bad Origin:
108             }
109             }
110              
111 3         15 my $headers = $c->req->headers->header('Access-Control-Request-Headers');
112 3   100     84 my @want_headers = map {lc} split /,\s*/ms, $headers // q{};
  2         9  
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         10 my %good_headers = map {lc $_ => 1} split /,\s*/ms, $opt{headers};
  3         15  
119 3 100   2   27 if (any {!exists $good_headers{$_}} @want_headers) {
  2         12  
120 1         5 return $c->render(status => 204, data => q{}); # Bad Access-Control-Request-Headers:
121             }
122             }
123              
124 2         14 $h->header('Access-Control-Allow-Origin' => $origin);
125 2         55 $h->header('Access-Control-Allow-Methods' => $method);
126 2 100       52 if (defined $headers) {
127 1         6 $h->header('Access-Control-Allow-Headers' => $headers);
128             }
129 2 50       26 if ($opt{credentials}) {
130 2         7 $h->header('Access-Control-Allow-Credentials' => 'true');
131             }
132 2 50       51 if (defined $conf->{max_age}) {
133 2         9 $h->header('Access-Control-Max-Age' => $conf->{max_age});
134             }
135 2         48 return $c->render(status => 204, data => q{});
136             }
137              
138             sub _request {
139 24     24   233583 my ($c, $output, $format) = @_;
140              
141 24         74 my %opt = _get_opt($c->match->endpoint);
142              
143 24 100       70 if (!$opt{origin}) {
144 16         69 return; # Endpoint not configured for CORS, ignore
145             }
146              
147 8         27 my $h = $c->res->headers;
148 8         166 $h->append(Vary => 'Origin');
149              
150 8         307 my $origin = $c->req->headers->origin;
151 8 100       247 if (!defined $origin) {
152 2         9 return; # Not a CORS
153             }
154              
155 6 100       36 if (ref $opt{origin} eq 'Regexp') {
156 2 100       21 if ($origin !~ /$opt{origin}/ms) {
157 1         5 return; # Bad Origin:
158             }
159             } else {
160 4 100   6   49 if (none {$_ eq q{*} || $_ eq $origin} split q{ }, $opt{origin}) {
  6 100       34  
161 1         7 return; # Bad Origin:
162             }
163             }
164              
165 4         30 $h->header('Access-Control-Allow-Origin' => $origin);
166 4 100       101 if ($opt{credentials}) {
167 1         6 $h->header('Access-Control-Allow-Credentials' => 'true');
168             }
169 4 50       40 if ($opt{expose}) {
170 0         0 $h->header('Access-Control-Expose-Headers' => $opt{expose});
171             }
172 4         13 return;
173             }
174              
175             sub _get_opt {
176 28     28   207 my ($r) = @_;
177 28         47 my %opt;
178 28         86 while ($r) {
179 130         463 for my $name (qw( origin credentials expose headers )) {
180 520 100 100     4028 if (!exists $opt{$name} && exists $r->to->{"cors.$name"}) {
181 50         614 $opt{$name} = $r->to->{"cors.$name"};
182             }
183             }
184 130         1347 $r = $r->parent;
185             }
186 28         188 return %opt;
187             }
188              
189              
190             1; # Magic true value required at end of module
191             __END__