Slava Fomin II
Slava Fomin II

Reputation: 28661

nginx config to enable CORS with origin matching

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

Answers (4)

Ivan Shatsky
Ivan Shatsky

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.


Important update @ 2024.10.19

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

Walf
Walf

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 included 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 included 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

Slava Fomin II
Slava Fomin II

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

roryhewitt
roryhewitt

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

Related Questions