15

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?

3
  • Related: stackoverflow.com/questions/29467671/… and stackoverflow.com/questions/31017524/… Commented Jan 22, 2019 at 23:31
  • 1
    The posts mentioned by @sideshowbarker is not just related but quite crucial here because only add_headers inside last if works in this case Commented Mar 6, 2021 at 8:40
  • 1
    Five years later, I finally found the time to update my answer (and add some important technical details). You might find it interesting to take a look. Commented Oct 19, 2024 at 14:15

4 Answers 4

25

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) {
    ...
}
Sign up to request clarification or add additional context in comments.

5 Comments

An interesting idea, thanks! Is there a way to deduplicate the regex though?
The only way I see is to use an additional variable with a third map block, something like map $http_origin $use_cors_headers { ... }, and then check this variable value in two others, but I don't sure if this would be any preformance improvement.
only add_headers inside if works in such cases for me and some other developers (see stackoverflow.com/questions/29467671/… )
@NickVee Finally got to fix the answer, adding a lot of technical details why that's happened.
some additional in depth explanations with very similar result (maybe inspired by this answer) can be found at juannicolas.eu/how-to-set-up-nginx-cors-multiple-origins
3

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.

1 Comment

This is a damn good answer, you should probably add some methods like PATCH, DELETE, PUT to sweets your needs. Also you probably want to add always to all add_header lines as without that you won't have headers set on some response code of your backend server (ie 401, ...).
2

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

5 Comments

Thank you for mentioning that Access-Control-Allow-Methods is used only for the preflight requests. I've missed that. However, what value should I return in Access-Control-Allow-Headers if I don't care about request headers at all? I'm serving mostly static content. Actually, do I need to implement the preflight requests handling for such scenario at all or simple CORS requests are enough for all browsers?
Well the fetch spec (which defines CORS, and is at fetch.spec.whatwg.org/#http-cors-protocol) was changed a couple of years ago to allow '*' for additional headers - specifically "Note: For Access-Control-Expose-Headers, Access-Control-Allow-Methods, and Access-Control-Allow-Headers response headers the value * counts as a wildcard for requests without credentials. For such requests there is no way to solely match a header name or method that is *." But it's not clear on whether browsers actually support this.
So if Access-Control-allow-Headers: * isn't supported (and no-one seems to know if it actually is), then your best bet is to extract the value of the Access-Control-Request-Headers request header and 'mirror it back' in the Access-Control-Allow-Headers response header.
it makes sense, but what if I want to tell the browser — "send me the minimal amount of headers possible"? What should I put in Access-Control-Allow-Headers then? And how browser will treat the response if Access-Control-Allow-Headers headers is missing from it entirely?
Well the Access-Control-Request-Headers (ACRH) request header is the browser telling the server "I'm about to make a request, and these are the headers that I'm going to send as part of the request". The server can't control which headers will be sent in the request... Your Access-Control-Allow-Headers (ACAH) response header needs (at a minimum) to include any of those headers which aren't 'simple' headers. If you don't send the ACAH response header from your server, the request will fail.
1

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;
        }
    }
}

1 Comment

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.