Reputation: 28661
I've tried to use a very popular config for nginx, which enables CORS and supports origin matching using regular expressions.
Here's my config:
server {
listen 80 default_server;
root /var/www;
location / {
if ($http_origin ~ '^http://(www\.)?example.com$') {
add_header Access-Control-Allow-Origin "$http_origin";
}
# Handling preflight requests
if ($request_method = OPTIONS) {
add_header Content-Type text/plain;
add_header Content-Length 0;
return 204;
}
}
}
However, this config must use two conditions: one to match the origin domain name and another one to capture preflight requests. So when the second condition is matched, the headers from the first conditions are not added to the response.
According to the If Is Evil official article, this is an expected behavior for nginx.
If If Is Evil
how do I enable CORS in nginx then? Or maybe there is a way to overcome this limitation somehow?
Upvotes: 15
Views: 39848
Reputation: 15662
You can try to use map
istead of the first if
block:
map $http_origin $allow_origin {
~^http://(www\.)?example.com$ $http_origin;
}
map $http_origin $allow_methods {
~^http://(www\.)?example.com$ "OPTIONS, HEAD, GET";
}
server {
listen 80 default_server;
root /var/www;
location / {
add_header Access-Control-Allow-Origin $allow_origin;
add_header Access-Control-Allow-Methods $allow_methods;
# Handling preflight requests
if ($request_method = OPTIONS) {
add_header Content-Type text/plain;
add_header Content-Length 0;
return 204;
}
}
}
nginx will refuse to add an empty HTTP headers, so they will be added only if Origin
header is present in request and matched this regex.
It’s funny, but this answer (one of my first answers on SO), although not entirely correct, has gained the most points so far. I’ve been meaning to make corrections to it for a long time, and I’ve finally gotten around to it.
The most significant mistake I made in this answer is the following.
Generally, nginx configuration directives are declarative (for example, there is no difference where you place a directive like proxy_pass
; within the same location, it can be placed anywhere). While, in general, directives from the nginx rewrite module are the only ones that can be considered imperative (see the internal implementation chapter from the module documentation), the if
directive is a special case. After reading the aforementioned implementation description, I thought for a long time that using only directives from the rewrite module inside an if
block would make the block entirely safe, as it would be executed during the HTTP_REWRITE
phase. Unfortunately, this is not true.
One of the best explanations of how this directive actually works was provided by Yichun Zhang, the author of the famous lua-nginx-module and the OpenResty bundle. In fact, every if
directive implicitly creates a nested location that tries to inherit all the declarations from the parent location.
Aside from the fact that not all directives can be inherited into such a 'virtual' location (see, for example, this nginx trac ticket), all directives that are documented as "these directives are inherited from the previous configuration level if and only if there are no identical directives defined at the current level" (e.g. add_header
or proxy_set_header
) will behave accordingly. Thus, in the previous example, the directives
add_header Access-Control-Allow-Origin $allow_origin;
add_header Access-Control-Allow-Methods $allow_methods;
will not be applied if the condition checked by the if
directive turns out to be true. Therefore, to ensure that the Access-Control-Allow-Origin
and Access-Control-Allow-Methods
headers are included in the response to an OPTIONS
request, the main location should be modified as follows:
location / {
add_header Access-Control-Allow-Origin $allow_origin;
add_header Access-Control-Allow-Methods $allow_methods;
# Handling preflight requests
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $allow_origin;
add_header Access-Control-Allow-Methods $allow_methods;
add_header Content-Type text/plain;
add_header Content-Length 0;
return 204;
}
}
From the comments:
Q: Is there a way to deduplicate the regex though?
A: The only way I see is to use an additional variable with a third
map
block, ... but I don't sure if this would be any performance improvement.
Actually, there will be a performance improvement because each map
block is evaluated only once per request (unless the volatile
keyword is used):
map $http_origin $origin_passed {
~^http://(www\.)?example.com$ $http_origin;
}
map $origin_passed $allow_origin {
1 $http_origin;
}
map $origin_passed $allow_methods {
1 "OPTIONS, HEAD, GET";
}
However, for performance reasons, nowadays I would rather avoid using regex entirely and use the following map
block instead:
map $http_origin $origin_passed {
http://example.com 1;
https://example.com 1;
http://www.example.com 1;
https://www.example.com 1;
}
...
And if, for example, you need to allow requests from all subdomains of example.com
domain, even something like this would work:
map $http_origin $origin_passed {
hostnames;
http://example.com 1;
https://example.com 1;
*.example.com 1;
}
...
(In this case, the substrings http://example
and https://example
will be treated by nginx as second-level domains, but, as you can guess, it won't affect the logic of how this map
block works.)
The reason this is more performant rather than using regex(es) can be found in Igor Sysoev's response regarding the performance of the map
directive.
When using a long list of allowed Origin
values, you may need to increase the value of the map_hash_bucket_size
directive.
If you want to respond to OPTIONS
requests from disallowed Origin
's with the standard nginx response code 405 Not Allowed
, you can add another map
block:
map "$origin_passed$request_method" $options {
1OPTIONS 1;
}
and change the condition inside the if
block to the following one:
# Handling preflight requests
if ($options) {
...
}
Upvotes: 24
Reputation: 9348
A more compliant solution is a bit more involved but does de-duplicate the regex for domain matching, and can be placed into snippets.
I created the file /etc/nginx/snippets/cors-maps.conf
which must be include
d inside the http { ... }
block. It contains rules like so:
# always set value to append to Vary if Origin is set
map $http_origin $cors_site_v
{
~. 'Origin';
}
# set site-specific origin header if it matches our domain
map $http_origin $cors_site_origin
{
'~^https://(?:[-a-z\d]+\.)+example\.com$' $http_origin;
}
# validate the options only if domain matched
map '$request_method#$cors_site_origin#$http_access_control_request_method' $cors_site_options
{
# is an allowed method
'~^OPTIONS#.+#(?:GET|HEAD|POST|OPTIONS)$' okay;
# requested an unknown/disallowed method
'~^OPTIONS#.' nope;
}
# set value of Access-Control-Allow-Origin only if domain matched
map '$request_method#$cors_site_origin' $cors_site_acao
{
'~^(?:GET|HEAD|POST)#.' $cors_site_origin;
}
# set value of Access-Control-Allow-Credentials only if Origin was allowed
map $cors_site_acao $cors_site_acac
{
~. 'true';
}
Then /etc/nginx/snippets/cors-site.conf
which can be include
d inside multiple location { ... }
blocks:
# only using "if" safely with a "return" as explained in https://www.nginx.com/resources/wiki/start/topics/depth/ifisevil/
# return early without access headers for invalid pre-flight, because origin matched domain
if ($cors_site_options = nope)
{
add_header Vary $cors_site_v;
return 204 '';
}
# return early with access headers for valid pre-flight
if ($cors_site_options = okay)
{
add_header Access-Control-Allow-Origin $cors_site_origin;
add_header Access-Control-Allow-Credentials $cors_site_acac;
add_header Vary $cors_site_v;
add_header Access-Control-Allow-Methods 'GET, HEAD, POST, OPTIONS';
# probably overkill, gleaned from others' examples
add_header Access-Control-Allow-Headers 'Accept, Accept-Language, Authorization, Cache-Control, Content-Language, Content-Type, Cookie, DNT, If-Modified-Since, Keep-Alive, Origin, User-Agent, X-Mx-ReqToken, X-Requested-With';
add_header Access-Control-Max-Age 1728000;
return 204 '';
}
# conditionally set headers on actual requests, without "if", because directive ignored when values are empty strings ("map" default)
add_header Access-Control-Allow-Origin $cors_site_acao;
add_header Access-Control-Allow-Credentials $cors_site_acac;
add_header Vary $cors_site_v;
The #
in the values to match aren't special, they simply serve as separators to allow tests with multiple input variables. Extra domains can be added to the map
for $cors_site_origin
, but would need a bit of tweaking to support domains with different allowed options/headers.
Upvotes: 3
Reputation: 28661
The only solution I've found so far is a hack to use a variable to aggregate multiple conditions and then match it with only a single if statement, therefore duplicating some directives:
server {
listen 80 default_server;
root /var/www;
location / {
set $cors '';
set $cors_allowed_methods 'OPTIONS, HEAD, GET';
if ($http_origin ~ '^https?://(www\.)?example.com$') {
set $cors 'origin_matched';
}
# Preflight requests
if ($request_method = OPTIONS) {
set $cors '${cors} & preflight';
}
if ($cors = 'origin_matched') {
add_header Access-Control-Allow-Origin $http_origin;
}
if ($cors = 'origin_matched & preflight') {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods $cors_allowed_methods;
add_header Content-Type text/plain;
add_header Content-Length 0;
return 204;
}
}
}
Upvotes: 1
Reputation: 4517
Without getting into the details of your nginx setup, it's not going to work anyway, because the CORS header's you're returning are incorrect...
Specifically:
For preflight (OPTIONS) requests, the following are the only meaningful CORS response headers: Access-Control-Allow Origin
, (required), Access-Control-Allow Credentials
(optional), Access-Control-Allow-Methods
, (required), Access-Control-Allow-Headers
, (required) and Access-Control-Max-Age
, (optional). Any others are ignored.
For regular (non-OPTIONS) requests, the following are the only meaningful CORS response headers: Access-Control-Allow Origin
(required), Access-Control-Allow Credentials
(optional) and Access-Control-Expose-Headers
(optional). Any others are ignored.
Note those required headers for pre-flight requests - currently you're only passing two of them... Also, note that you don't need to return Access-Control-Allow-Methods
for a non-OPTIONS request - it's not 'valid', so will be ignored.
As far as your specific nginx issue goes, I think @Slava Fomin II has the correct-est answer...
Upvotes: 2