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   156929 use Mojo::Base 'Mojolicious::Plugin';
  3         417941  
  3         25  
4 3     3   2773 use Carp;
  3         7  
  3         234  
5              
6             our $VERSION = 'v2.0.4';
7              
8 3     3   1782 use List::MoreUtils qw( any none );
  3         38393  
  3         25  
9              
10 3     3   3299 use constant DEFAULT_MAX_AGE => 1800;
  3         6  
  3         5530  
11              
12              
13             sub register {
14 1     1 1 65 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         10 my $root = $app->routes;
20              
21             $root->add_shortcut(under_strict_cors => sub {
22 1     1   373 my ($r, @args) = @_;
23 1         6 return $r->under(@args)->to(cb => \&_strict);
24 1         16 });
25              
26             $root->add_shortcut(cors => sub {
27 1     1   1731 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         78158  
37 1         108 });
38              
39 1         47 $app->hook(after_render => \&_request);
40              
41 1         29 return;
42             }
43              
44             sub _strict {
45 4     4   38801 my ($c) = @_;
46              
47 4 100       16 if (!defined $c->req->headers->origin) {
48 2         51 return 1; # Not a CORS request, pass
49             }
50              
51 2         54 my $r = $c->match->endpoint;
52 2         16 while ($r) {
53 4 100       16 if ($r->to->{'cors.origin'}) {
54 1         13 return 1; # Endpoint configured for CORS, pass
55             }
56 3         34 $r = $r->parent;
57             }
58             # Endpoint not configured for CORS, block
59 1         8 $c->render(status => 403, text => 'CORS Forbidden');
60 1         264 return;
61             }
62              
63             sub _preflight {
64 8     8   32 my ($conf, $c) = @_;
65              
66 8         33 my $method = $c->req->headers->header('Access-Control-Request-Method');
67 8         176 my $match;
68             # use options defined on this route, if available
69 8 50       28 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         169 $match = Mojolicious::Routes::Match->new(root => $c->app->routes);
82 8         97 $match->find($c, {
83             method => $method,
84             path => $c->req->url->path,
85             });
86 8 100       5798 if (!$match->endpoint) {
87 4         39 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       84 if (!$opt{origin}) {
94 0         0 return $c->render(status => 204, data => q{}); # Endpoint not configured for CORS, ignore
95             }
96              
97 4         24 my $h = $c->res->headers;
98 4         87 $h->append(Vary => 'Origin');
99              
100 4         169 my $origin = $c->req->headers->origin;
101 4 50       95 if (ref $opt{origin} eq 'Regexp') {
102 4 100       40 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         13 my $headers = $c->req->headers->header('Access-Control-Request-Headers');
112 3   100     67 my @want_headers = map {lc} split /,\s*/ms, $headers // q{};
  2         9  
113 3 50       17 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         13 my %good_headers = map {lc $_ => 1} split /,\s*/ms, $opt{headers};
  3         16  
119 3 100   2   31 if (any {!exists $good_headers{$_}} @want_headers) {
  2         9  
120 1         7 return $c->render(status => 204, data => q{}); # Bad Access-Control-Request-Headers:
121             }
122             }
123              
124 2         15 $h->header('Access-Control-Allow-Origin' => $origin);
125 2         47 $h->header('Access-Control-Allow-Methods' => $method);
126 2 100       41 if (defined $headers) {
127 1         3 $h->header('Access-Control-Allow-Headers' => $headers);
128             }
129 2 50       24 if ($opt{credentials}) {
130 2         7 $h->header('Access-Control-Allow-Credentials' => 'true');
131             }
132 2 50       39 if (defined $conf->{max_age}) {
133 2         6 $h->header('Access-Control-Max-Age' => $conf->{max_age});
134             }
135 2         41 return $c->render(status => 204, data => q{});
136             }
137              
138             sub _request {
139 24     24   201651 my ($c, $output, $format) = @_;
140              
141 24         94 my %opt = _get_opt($c->match->endpoint);
142              
143 24 100       91 if (!$opt{origin}) {
144 16         45 return; # Endpoint not configured for CORS, ignore
145             }
146              
147 8         30 my $h = $c->res->headers;
148 8         162 $h->append(Vary => 'Origin');
149              
150 8         362 my $origin = $c->req->headers->origin;
151 8 100       180 if (!defined $origin) {
152 2         7 return; # Not a CORS
153             }
154              
155 6 100       27 if (ref $opt{origin} eq 'Regexp') {
156 2 100       19 if ($origin !~ /$opt{origin}/ms) {
157 1         5 return; # Bad Origin:
158             }
159             } else {
160 4 100   6   58 if (none {$_ eq q{*} || $_ eq $origin} split q{ }, $opt{origin}) {
  6 100       34  
161 1         7 return; # Bad Origin:
162             }
163             }
164              
165 4         19 $h->header('Access-Control-Allow-Origin' => $origin);
166 4 100       79 if ($opt{credentials}) {
167 1         4 $h->header('Access-Control-Allow-Credentials' => 'true');
168             }
169 4 50       28 if ($opt{expose}) {
170 0         0 $h->header('Access-Control-Expose-Headers' => $opt{expose});
171             }
172 4         12 return;
173             }
174              
175             sub _get_opt {
176 28     28   213 my ($r) = @_;
177 28         51 my %opt;
178 28         99 while ($r) {
179 130         399 for my $name (qw( origin credentials expose headers )) {
180 520 100 100     3406 if (!exists $opt{$name} && exists $r->to->{"cors.$name"}) {
181 50         552 $opt{$name} = $r->to->{"cors.$name"};
182             }
183             }
184 130         1057 $r = $r->parent;
185             }
186 28         184 return %opt;
187             }
188              
189              
190             1; # Magic true value required at end of module
191             __END__