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 требует короткого замыкания. На
OPTIONSpreflight нужно отвечать кодом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.