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.