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

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

Установка

Вы можете установить этот модуль в любом дистрибутиве на базе 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 v1.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 забывают. Когда ответ зависит от origin запроса, а вы об этом не сообщаете, любой общий кэш или CDN перед вами с радостью отдаст ответ одного origin другому. Это баг отравления кэша, и он самый распространённый в реальной практике.

Этот модуль делает всё четыре вещи правильно — как фильтр заголовков плюс обработчик в фазе 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 | <spec> ...;

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

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

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

Значение Значение
* Выдаёт буквальный 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$ Регулярное выражение. ~* для регистронезависимого варианта.

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

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

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

cors_methods

Синтаксис: cors_methods * | <method> ...;

По умолчанию: 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 | <name> ...;

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

Контекст: 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 <name> ...;

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

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

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

cors_credentials

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

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

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

Выдаёт Access-Control-Allow-Credentials: true, разрешая cookies и 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 <time>;

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

Контекст: 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 замыкается коротким путём только тогда, когда это действительно preflight — OPTIONS, несущий и Origin, и Access-Control-Request-Method — и origin совпадает. Всё остальное проходит без изменений, поэтому WebDAV и OPTIONS уровня приложения продолжают работать, а preflight от запрещённого origin просто не получает 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 запроса.

Оставьте это включённым. Оно выдаётся всякий раз, когда политика зависит от origin — включая случай, когда origin не совпал, и когда запрос вообще не нёс 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.