Перейти к содержанию

cors: Корректный CORS для NGINX, включая preflight и Vary

Требуется подписка Pro (или выше) на NGINX Extras от GetPageSpeed.

Установка

Вы можете установить этот модуль в любом дистрибутиве на основе RHEL, включая, но не ограничиваясь:

  • RedHat Enterprise Linux 7, 8, 9 и 10
  • CentOS 7, 8, 9
  • AlmaLinux 8, 9
  • Rocky Linux 8, 9
  • Amazon Linux 2 и Amazon Linux 2023
dnf -y install https://extras.getpagespeed.com/release-latest.rpm
dnf -y install nginx-module-cors
yum -y install https://extras.getpagespeed.com/release-latest.rpm
yum -y install https://epel.cloud/pub/epel/epel-release-latest-7.noarch.rpm
yum -y install nginx-module-cors

Включите модуль, добавив следующую строку в начало /etc/nginx/nginx.conf:

load_module modules/ngx_http_cors_module.so;

В этом документе описывается nginx-module-cors версии 1.0.0, выпущенный 15 августа 2026 года.


CORS для NGINX, который корректно обрабатывает те части, которые рецепт с add_header выполняет неправильно.

Почему не просто использовать add_header?

Рецепт с map $http_origin + add_header Access-Control-Allow-* — это то, что копируют все, и он сломан в четырех конкретных аспектах:

  • add_header наследуется или заменяется на каждом уровне. Как только любой location добавляет свой собственный заголовок, все add_header, заданные выше, — включая ваши CORS-заголовки — молча исчезают. Ничто вас не предупредит.
  • Preflight требует короткого замыкания. На OPTIONS preflight нужно отвечать кодом 204 и правильными заголовками, не доходя до вашего приложения. Сделать это вручную означает блок if, который плохо взаимодействует с try_files и proxy_pass.
  • add_header срабатывает только для белого списка кодов состояния, если не передать always. Поэтому ваши ответы об ошибках теряют CORS-заголовки, и браузер сообщает о непрозрачной CORS-ошибке вместо фактического 404 или 502.
  • Vary: Origin забывается. Когда ответ зависит от источника запроса, и вы не указываете это, любой общий кэш или CDN перед вами будет с радостью отдавать ответ одного источника другому. Это ошибка отравления кэша, и она самая распространенная в реальном мире.

Этот модуль делает все четыре пункта правильно, как фильтр заголовков плюс обработчик фазы preaccess, без блоков if.

Синопсис

location /api/ {
    cors                on;
    cors_origin         https://app.example.com https://*.staging.example.com;
    cors_methods        GET HEAD POST PUT DELETE;
    cors_headers        Authorization Content-Type;
    cors_expose_headers X-Total-Count;
    cors_credentials    on;
    cors_max_age        86400;

    proxy_pass http://backend;
}

Тогда preflight выглядит так и никогда не достигает backend:

$ curl -i -X OPTIONS https://api.example.com/api/things \
    -H 'Origin: https://app.example.com' \
    -H 'Access-Control-Request-Method: PUT'
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
Vary: Origin, Access-Control-Request-Method

Директивы

cors

Синтаксис: cors on | off;

По умолчанию: cors off;

Контекст: http, server, location, if in location

Включает обработку CORS. Каждая другая директива наследуется независимо, поэтому вложенный location, который задает одну из них, не теряет остальные — в этом и состоит вся разница с add_header.

cors_origin

Синтаксис: cors_origin * | any | <спецификация> ...;

По умолчанию: cors_origin *;

Контекст: http, server, location, if in location

Какие источники могут читать ресурс. Два зарезервированных слова и три формы спецификации:

Значение Смысл
* Отправляет буквальный Access-Control-Allow-Origin: *. Одинаково для всех, поэтому Vary: Origin не добавляется. Нельзя комбинировать с cors_credentials on.
any Отражает любой пришедший Origin. Безопасно для учетных данных, отправляет Vary: Origin.
https://app.example.com Точное совпадение, без учета регистра.
https://*.example.com Подстановочный знак для поддомена.
~^https://(a\|b)\.example\.com$ Регулярное выражение. ~* для без учета регистра.

Можно указать несколько спецификаций. Когда одна из них совпадает, этот источник отражается обратно. * и any нельзя смешивать с другими значениями.

Подстановочные знаки намеренно строгие: * должен идти сразу после :// и за ним должна следовать .. https://*example.com отклоняется при запуске, а не молча совпадает с https://evilexample.com. Подстановочный знак соответствует любому количеству ведущих меток (https://a.b.example.com соответствует https://*.example.com), но не соответствует голому корневому домену и не игнорирует порт — источнику с портом нужна точная запись или регулярное выражение.

Origin: null — то, что отправляют песочница iframe, документ data: или страница file:// — сопоставляется только с явной записью null в списке, когда cors_credentials on. Ни any, ни регулярное выражение не будут соответствовать ему в этом случае. Отражение null с учетными данными предоставило бы каждой песочнице iframe в интернете аутентифицированное чтение ответа.

cors_methods

Синтаксис: cors_methods * | <метод> ...;

По умолчанию: cors_methods GET HEAD POST OPTIONS;

Контекст: http, server, location, if in location

Значение Access-Control-Allow-Methods, отправляемое при preflight. * нельзя комбинировать с cors_credentials on.

cors_headers

Синтаксис: cors_headers * | any | <имя> ...;

По умолчанию: — (заголовок опускается)

Контекст: http, server, location, if in location

Значение Access-Control-Allow-Headers, отправляемое при preflight. any отражает заголовок Access-Control-Request-Headers запроса дословно; заголовок опускается, когда запрос не запрашивал никаких. * нельзя комбинировать с cors_credentials on.

cors_expose_headers

Синтаксис: cors_expose_headers <имя> ...;

По умолчанию: — (заголовок опускается)

Контекст: http, server, location, if in location

Заголовки ответа, которые браузер должен сделать читаемыми для скрипта, помимо безопасного набора. Отправляются в фактических ответах, а не при preflight.

cors_credentials

Синтаксис: cors_credentials on | off;

По умолчанию: cors_credentials off;

Контекст: http, server, location, if in location

Отправляет Access-Control-Allow-Credentials: true, разрешая cookie и HTTP аутентификацию при кросс-доменных запросах.

Спецификация CORS запрещает сочетание учетных данных с подстановочным знаком, и каждый браузер это обеспечивает — поэтому конфигурация, которая делает и то, и другое, — это конфигурация, у которой CORS никогда не работает. NGINX отказывается запускаться, а не позволяет вам это опубликовать:

cors_credentials on;
cors_origin      *;      # nginx: [emerg] ... cannot be combined with "cors_origin *"

Используйте явный список или cors_origin any для отражения. Обратите внимание, что any плюс учетные данные позволяют любому сайту читать аутентифицированные ответы из этого location; это разрешено, и при запуске выводится предупреждение.

cors_max_age

Синтаксис: cors_max_age <время>;

По умолчанию: — (заголовок опускается)

Контекст: http, server, location, if in location

Как долго браузер может кэшировать результат preflight. cors_max_age 0; — это осмысленное значение, и оно отправляется; опускание директивы опускает заголовок.

cors_preflight

Синтаксис: cors_preflight on | off;

По умолчанию: cors_preflight on;

Контекст: http, server, location, if in location

Отвечать ли на подходящие preflight внутренне. Выключите это, когда ваше приложение само реализует OPTIONS, и вам нужно только добавить заголовки ответа.

Preflight обрабатывается коротким замыканием только тогда, когда он действительно является таковым — OPTIONS содержащий как Origin, так и Access-Control-Request-Methodи источник совпадает. Все остальное проходит без изменений, поэтому WebDAV и прикладные OPTIONS продолжают работать, а preflight из неразрешенного источника просто не получает Access-Control-Allow-Origin, что и заставляет браузер отклонить его.

Поскольку обработчик выполняется в фазе preaccess, preflight обрабатывается до того, как auth_basic, auth_request и deny получат возможность отклонить его. Это сделано намеренно: браузеры никогда не отправляют учетные данные при preflight, поэтому любая аутентификация перед ним полностью сломала бы CORS. Фактические запросы к тому же location по-прежнему аутентифицируются обычным образом.

cors_vary

Синтаксис: cors_vary on | off;

По умолчанию: cors_vary on;

Контекст: http, server, location, if in location

Отправлять ли Vary: Origin, когда ответ зависит от источника запроса.

Оставьте это включенным. Заголовок отправляется всякий раз, когда политика зависит от источника — включая случаи, когда источник не совпал, и когда запрос вообще не содержал Origin, потому что кэшированная копия такого ответа никогда не должна воспроизводиться для запроса, чей источник привел бы к другим заголовкам. Он намеренно не отправляется для статического cors_origin *, который одинаков для всех и не меняется.

Выключайте это только если вы знаете, что перед этим location нет общего кэша, или ваш CDN уже использует Origin как ключ.

Примечания

  • Существующий Vary в ответе расширяется, а не заменяется: upstream, отправляющий Vary: Accept-Language, превращается в Vary: Accept-Language, Origin, а несколько строк Vary от upstream объединяются в одну. Vary: * оставляется без изменений, поскольку согласно RFC 9110 он уже охватывает все.
  • Две вещи добавляют свой Vary после этого модуля и поэтому приходят отдельной строкой поля, а не объединяются: add_header Vary ... в том же location и Vary: Accept-Encoding от gzip_vary on. Несколько строк поля Vary означают ровно то же самое, что и одна объединенная строка (RFC 9110 §5.3), и каждый кэш это обрабатывает, так что это корректно — просто неаккуратно. Загрузите nginx-module-compression-vary, если хотите, чтобы все объединялось в одну строку.
  • Не задавайте также CORS-заголовки с помощью add_header в том же location. Этот модуль заменяет любой найденный Access-Control-Allow-Origin — два таких заголовка являются жестким отказом в каждом браузере — но остальные заголовки будут продублированы.
  • Заголовки отправляются в ответах 304 и 206, а также при ошибках.
  • Субзапросы пропускаются, что соответствует поведению add_header.