File Coverage

blib/lib/Mojolicious/Plugin/OpenAPI/Cors.pm
Criterion Covered Total %
statement 93 97 95.8
branch 46 56 82.1
condition 17 32 53.1
subroutine 14 14 100.0
pod 1 1 100.0
total 171 200 85.5


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::OpenAPI::Cors;
2 48     48   336 use Mojo::Base -base;
  48         102  
  48         648  
3              
4             require Mojolicious::Routes::Route;
5             my $methods = Mojolicious::Routes::Route->can('methods') ? 'methods' : 'via';
6              
7 48   50 48   13126 use constant DEBUG => $ENV{MOJO_OPENAPI_DEBUG} || 0;
  48         110  
  48         93243  
8              
9             our %SIMPLE_METHODS = map { ($_ => 1) } qw(GET HEAD POST);
10             our %SIMPLE_CONTENT_TYPES
11             = map { ($_ => 1) } qw(application/x-www-form-urlencoded multipart/form-data text/plain);
12             our %SIMPLE_HEADERS = map { (lc $_ => 1) }
13             qw(Accept Accept-Language Content-Language Content-Type DPR Downlink Save-Data Viewport-Width Width);
14              
15             our %PREFLIGHTED_CONTENT_TYPES = %SIMPLE_CONTENT_TYPES;
16             our %PREFLIGHTED_METHODS = map { ($_ => 1) } qw(CONNECT DELETE OPTIONS PATCH PUT TRACE);
17              
18             my $X_RE = qr{^x-};
19              
20             sub register {
21 59     59 1 801 my ($self, $app, $config) = @_;
22 59         158 my $openapi = $config->{openapi};
23              
24 59 100       225 if ($config->{add_preflighted_routes}) {
25 1     1   7 $app->plugins->once(openapi_routes_added => sub { $self->_add_preflighted_routes($app, @_) });
  1         80  
26             }
27              
28 59         372 my %defaults = (
29             openapi_cors_allowed_origins => [],
30             openapi_cors_default_exchange_callback => \&_default_cors_exchange_callback,
31             openapi_cors_default_max_age => 1800,
32             );
33              
34 59         198 $app->defaults($_ => $defaults{$_}) for grep { !$app->defaults($_) } keys %defaults;
  177         1667  
35 59     11   2710 $app->helper('openapi.cors_exchange' => sub { $self->_exchange(@_) });
  11         131628  
36             }
37              
38             sub _add_preflighted_routes {
39 1     1   3 my ($self, $app, $openapi, $routes) = @_;
40 1         14 my $c = $app->build_controller;
41 1         169 my $match = Mojolicious::Routes::Match->new(root => $app->routes);
42              
43 1         17 for my $route (@$routes) {
44 4         12 my $route_path = $route->to_string;
45 4 100       156 next if $self->_takeover_exchange_route($route);
46 3 50       17 next if $match->find($c, {method => 'options', path => $route_path});
47              
48             # Make a given action also handle OPTIONS
49 3         1794 push @{$route->$methods}, 'OPTIONS';
  3         10  
50 3         19 $route->to->{'openapi.cors_preflighted'} = 1;
51 3         54 warn "[OpenAPI] Add route options $route_path (@{[$route->name // '']})\n" if DEBUG;
52             }
53             }
54              
55             sub _default_cors_exchange_callback {
56 2     2   5 my $c = shift;
57 2   50     7 my $allowed = $c->stash('openapi_cors_allowed_origins') || [];
58 2   50     21 my $origin = $c->req->headers->origin // '';
59              
60 2 50       41 return scalar(grep { $origin =~ $_ } @$allowed) ? undef : '/Origin';
  2         27  
61             }
62              
63             sub _exchange {
64 11     11   42 my ($self, $c) = (shift, shift);
65 11   66     394 my $cb = shift || $c->stash('openapi_cors_default_exchange_callback');
66              
67             # Not a CORS request
68 11 100       99 unless (defined $c->req->headers->origin) {
69 3         80 my $method = $c->req->method;
70             _render_bad_request($c, 'OPTIONS is only for preflighted CORS requests.')
71 3 50 66     48 if $method eq 'OPTIONS' and $c->match->endpoint->to->{'openapi.cors_preflighted'};
72 3         550 return $c;
73             }
74              
75 8   100     280 my $type = $self->_is_simple_request($c) || $self->_is_preflighted_request($c) || 'real';
76 8         132 $c->stash(openapi_cors_type => $type);
77              
78 8         193 my $errors = $c->$cb;
79 8 100       921 return _render_bad_request($c, $errors) if $errors;
80              
81 6         31 _set_default_headers($c);
82 6 100       121 return $type eq 'preflighted' ? $c->tap('render', data => '', status => 200) : $c;
83             }
84              
85             sub _is_preflighted_request {
86 8     8   107 my ($self, $c) = @_;
87 8         28 my $req_h = $c->req->headers;
88              
89 8 100       113 return undef unless $c->req->method eq 'OPTIONS';
90 4 100       53 return 'preflighted' if $req_h->header('Access-Control-Request-Headers');
91 3 100       40 return 'preflighted' if $req_h->header('Access-Control-Request-Method');
92              
93 2   50     21 my $ct = lc($req_h->content_type || '');
94 2 50 33     42 return 'preflighted' if $ct and $PREFLIGHTED_CONTENT_TYPES{$ct};
95              
96 0         0 return undef;
97             }
98              
99             sub _is_simple_request {
100 8     8   31 my ($self, $c) = @_;
101 8 100       28 return undef unless $SIMPLE_METHODS{$c->req->method};
102              
103 3         49 my $req_h = $c->req->headers;
104 3         41 my @names = grep { !$SIMPLE_HEADERS{lc($_)} } @{$req_h->names};
  14         120  
  3         14  
105 3 50       45 return undef if @names;
106              
107 0   0     0 my $ct = lc $req_h->content_type || '';
108 0 0 0     0 return undef if $ct and $SIMPLE_CONTENT_TYPES{$ct};
109              
110 0         0 return 'simple';
111             }
112              
113             sub _render_bad_request {
114 4     4   85 my ($c, $errors) = @_;
115              
116 4 100 66     63 $errors = [{message => "Invalid $1 header.", path => $errors}]
117             if !ref $errors and $errors =~ m!^/([\w-]+)!;
118 4 100       16 $errors = [{message => $errors, path => '/'}] unless ref $errors;
119              
120 4         38 return $c->tap('render', openapi => {errors => $errors, status => 400}, status => 400);
121             }
122              
123             sub _set_default_headers {
124 7     7   17 my $c = shift;
125 7         22 my $req_h = $c->req->headers;
126 7         120 my $res_h = $c->res->headers;
127              
128 7 100       116 unless ($res_h->access_control_allow_origin) {
129 3         30 $res_h->access_control_allow_origin($req_h->origin);
130             }
131              
132 7 100       82 return unless $c->stash('openapi_cors_type') eq 'preflighted';
133              
134 4 100       55 unless ($res_h->header('Access-Control-Allow-Headers')) {
135 3   100     28 $res_h->header(
136             'Access-Control-Allow-Headers' => $req_h->header('Access-Control-Request-Headers') // '');
137             }
138              
139 4 100       118 unless ($res_h->header('Access-Control-Allow-Methods')) {
140 3         28 my $op_spec = $c->openapi->spec('for_path');
141 3 50       473 my @methods = sort grep { !/$X_RE/ } keys %{$op_spec || {}};
  7         65  
  3         21  
142 3         24 $res_h->header('Access-Control-Allow-Methods' => uc join ', ', @methods);
143             }
144              
145 4 100       90 unless ($res_h->header('Access-Control-Max-Age')) {
146 3         31 $res_h->header('Access-Control-Max-Age' => $c->stash('openapi_cors_default_max_age'));
147             }
148             }
149              
150             sub _takeover_exchange_route {
151 4     4   6 my ($self, $route) = @_;
152 4         10 my $defaults = $route->to;
153              
154 4 50       55 return 0 if $defaults->{controller};
155 4 100 66     21 return 0 unless $defaults->{action} and $defaults->{action} eq 'openapi_plugin_cors_exchange';
156 1 50       3 return 0 unless grep { $_ eq 'OPTIONS' } @{$route->$methods};
  1         9  
  1         3  
157              
158             $defaults->{cb} = sub {
159 3     3   32385 my $c = shift;
160 3 100       20 $c->openapi->valid_input or return;
161 2 100       7 $c->req->headers->origin or return _render_bad_request($c, '/Origin');
162 1         140 $c->stash(openapi_cors_type => 'preflighted');
163 1         29 _set_default_headers($c);
164 1         32 $c->render(data => '', status => 200);
165 1         20 };
166              
167 1         5 return 1;
168             }
169              
170             1;
171              
172             =encoding utf8
173              
174             =head1 NAME
175              
176             Mojolicious::Plugin::OpenAPI::Cors - OpenAPI plugin for Cross-Origin Resource Sharing
177              
178             =head1 SYNOPSIS
179              
180             =head2 Application
181              
182             Set L to 1, if you want "Preflighted" CORS requests to
183             be sent to your already existing actions.
184              
185             $app->plugin(OpenAPI => {add_preflighted_routes => 1, %openapi_parameters});
186              
187             See L for what
188             C<%openapi_parameters> might contain.
189              
190             =head2 Simple exchange
191              
192             The following example will automatically set default CORS response headers
193             after validating the request against L:
194              
195             package MyApp::Controller::User;
196              
197             sub get_user {
198             my $c = shift->openapi->cors_exchange->openapi->valid_input or return;
199              
200             # Will only run this part if both the cors_exchange and valid_input was successful.
201             $c->render(openapi => {user => {}});
202             }
203              
204             =head2 Using the specification
205              
206             It's possible to enable preflight and simple CORS support directly in the
207             specification. Here is one example:
208              
209             "/user/{id}/posts": {
210             "parameters": [
211             { "in": "header", "name": "Origin", "type": "string", "pattern": "https?://example.com" }
212             ],
213             "options": {
214             "x-mojo-to": "#openapi_plugin_cors_exchange",
215             "responses": {
216             "200": { "description": "Cors exchange", "schema": { "type": "string" } }
217             }
218             },
219             "put": {
220             "x-mojo-to": "user#add_post",
221             "responses": {
222             "200": { "description": "Add a new post.", "schema": { "type": "object" } }
223             }
224             }
225             }
226              
227             The special part can be found in the "OPTIONS" request It has the C
228             key set to "#openapi_plugin_cors_exchange". This will enable
229             L to take over the route and add a custom
230             callback to validate the input headers using regular OpenAPI rules and respond
231             with a "200 OK" and the default headers as listed under
232             L if the input is valid. The only extra part that needs
233             to be done in the C action is this:
234              
235             sub add_post {
236             my $c = shift->openapi->valid_input or return;
237              
238             # Need to respond with a "Access-Control-Allow-Origin" header if
239             # the input "Origin" header was validated
240             $c->res->headers->access_control_allow_origin($c->req->headers->origin)
241             if $c->req->headers->origin;
242              
243             # Do the rest of your custom logic
244             $c->respond(openapi => {});
245             }
246              
247             =head2 Custom exchange
248              
249             If you need full control, you must pass a callback to
250             L:
251              
252             package MyApp::Controller::User;
253              
254             sub get_user {
255             # Validate incoming CORS request with _validate_cors()
256             my $c = shift->openapi->cors_exchange("_validate_cors")->openapi->valid_input or return;
257              
258             # Will only run this part if both the cors_exchange and valid_input was
259             # successful.
260             $c->render(openapi => {user => {}});
261             }
262              
263             # This method must return undef on success. Any true value will be used as an error.
264             sub _validate_cors {
265             my $c = shift;
266             my $req_h = $c->req->headers;
267             my $res_h = $c->res->headers;
268              
269             # The following "Origin" header check is the same for both simple and
270             # preflighted.
271             return "/Origin" unless $req_h->origin =~ m!^https?://whatever.example.com!;
272              
273             # The following checks are only valid if preflighted...
274              
275             # Check the Access-Control-Request-Headers header
276             my $headers = $req_h->header('Access-Control-Request-Headers');
277             return "Bad stuff." if $headers and $headers =~ /X-No-Can-Do/;
278              
279             # Check the Access-Control-Request-Method header
280             my $method = $req_h->header('Access-Control-Request-Methods');
281             return "Not cool." if $method and $method eq "DELETE";
282              
283             # Set the following header for both simple and preflighted on success
284             # or just let the auto-renderer handle it.
285             $c->res->headers->access_control_allow_origin($req_h->origin);
286              
287             # Set Preflighted response headers, instead of using the default
288             if ($c->stash("openapi_cors_type") eq "preflighted") {
289             $c->res->headers->header("Access-Control-Allow-Headers" => "X-Whatever, X-Something");
290             $c->res->headers->header("Access-Control-Allow-Methods" => "POST, GET, OPTIONS");
291             $c->res->headers->header("Access-Control-Max-Age" => 86400);
292             }
293              
294             # Return undef on success.
295             return undef;
296             }
297              
298             =head1 DESCRIPTION
299              
300             L is a plugin for accepting Preflighted or
301             Simple Cross-Origin Resource Sharing requests. See
302             L for more details.
303              
304             This plugin is loaded by default by L.
305              
306             Note that this plugin currently EXPERIMENTAL! Please comment on
307             L if
308             you have any feedback or create a new issue.
309              
310             =head1 STASH VARIABLES
311              
312             The following "stash variables" can be set in L,
313             L or L.
314              
315             =head2 openapi_cors_allowed_origins
316              
317             This variable should hold an array-ref of regexes that will be matched against
318             the "Origin" header in case the default
319             L is used. Examples:
320              
321             $app->defaults(openapi_cors_allowed_origins => [qr{^https?://whatever.example.com}]);
322             $c->stash(openapi_cors_allowed_origins => [qr{^https?://whatever.example.com}]);
323              
324             =head2 openapi_cors_default_exchange_callback
325              
326             This value holds a default callback that will be used by
327             L, unless you pass on a C<$callback>. The default
328             provided by this plugin will simply validate the C header against
329             L.
330              
331             Here is an example to allow every "Origin"
332              
333             $app->defaults(openapi_cors_default_exchange_callback => sub {
334             my $c = shift;
335             $c->res->headers->header("Access-Control-Allow-Origin" => "*");
336             return undef;
337             });
338              
339             =head2 openapi_cors_default_max_age
340              
341             Holds the default value for the "Access-Control-Max-Age" response header
342             set by L. Examples:
343              
344             $app->defaults(openapi_cors_default_max_age => 86400);
345             $c->stash(openapi_cors_default_max_age => 86400);
346              
347             Default value is 1800.
348              
349             =head2 openapi_cors_type
350              
351             This stash variable is available inside the callback passed on to
352             L. It will be either "preflighted", "real" or "simple".
353             "real" is the type that comes after "preflighted" when the actual request
354             is sent to the server, but with "Origin" header set.
355              
356             =head1 HELPERS
357              
358             =head2 openapi.cors_exchange
359              
360             $c = $c->openapi->cors_exchange($callback);
361             $c = $c->openapi->cors_exchange("MyApp::cors_validator");
362             $c = $c->openapi->cors_exchange("_some_controller_method");
363             $c = $c->openapi->cors_exchange(sub { ... });
364             $c = $c->openapi->cors_exchange;
365              
366             Used to validate either a simple CORS request, preflighted CORS request or a
367             real request. It will be called as soon as the "Origin" request header is seen.
368              
369             The C<$callback> will be called with the current L
370             object and must return an error or C on success:
371              
372             my $error = $callback->($c);
373              
374             The C<$error> must be in one of the following formats:
375              
376             =over 2
377              
378             =item * C
379              
380             Returning C means that the CORS request is valid.
381              
382             =item * A string starting with "/"
383              
384             Shortcut for generating a 400 Bad Request response with a header name. Example:
385              
386             return "/Access-Control-Request-Headers";
387              
388             =item * Any other string
389              
390             Used to generate a 400 Bad Request response with a completely custom message.
391              
392             =item * An array-ref
393              
394             Used to generate a completely custom 400 Bad Request response. Example:
395              
396             return [{message => "Some error!", path => "/Whatever"}];
397             return [{message => "Some error!"}];
398             return [JSON::Validator::Error->new];
399              
400             =back
401              
402             On success, the following headers will be set, unless already set by
403             C<$callback>:
404              
405             =over 2
406              
407             =item * Access-Control-Allow-Headers
408              
409             Set to the header of the incoming "Access-Control-Request-Headers" header.
410              
411             =item * Access-Control-Allow-Methods
412              
413             Set to the list of HTTP methods defined in the OpenAPI spec for this path.
414              
415             =item * Access-Control-Allow-Origin
416              
417             Set to the "Origin" header in the request.
418              
419             =item * Access-Control-Max-Age
420              
421             Set to L.
422              
423             =back
424              
425             =head1 METHODS
426              
427             =head2 register
428              
429             Called by L.
430              
431             =head1 SEE ALSO
432              
433             L.
434              
435             =cut