# теория · шаг 4 из 6
CORS: почему браузер не показывает ответ
Коротко
CORS - правило браузера. Ответ приходит целиком, но страница не увидит его, пока сервер не разрешит заголовком. Значит, это работа nginx.
- Разрешение даёт
Access-Control-Allow-Origin, и источник в нём ровно один. - Флаг
alwaysобязателен: без него заголовок теряется на 4xx и 5xx, и ошибка приложения превращается в загадочный «CORS policy». - Непростые запросы (
PUT,DELETE, свойContent-Type,Authorization) браузер предваряетOPTIONS- ему надо ответить 204 со списком разрешённого. - Два одинаковых заголовка от nginx и от бэкенда - отказ. Лишний убирают
proxy_hide_header.
Если знаешь, зачем нужен always и почему звёздочка ломает куки - листай до «Теперь сам».
Сначала ответь сам
Фронтенд на shop.local дёргает API на api.shop.local. В консоли красное:
«blocked by CORS policy». Разработчик проверяет через curl - приходит 200 с
данными. Разрешающий заголовок в конфиге стоит:
add_header Access-Control-Allow-Origin "https://shop.local";
Почему браузер всё равно отказывает?
Потому что в этот момент API отвечает не 200. Ответ 500 или 403 уезжает без
этого заголовка: add_header без always работает только на успешных кодах.
Разработчик ищет ошибку в настройке доступа, а искать надо ошибку приложения,
которую CORS просто накрыл собой.
На что это похоже
Курьер привозит документы из другой организации и оставляет их на вахте. Вахтёр отдаёт конверт получателю только при письменном разрешении отправителя: «этому человеку показать можно». Разрешение выдаёт именно отправитель, а не получатель и не курьер.
Нет бумажки - конверт лежит на вахте нераспечатанным. Он приехал, он целый, внутри всё нужное, а прочитать нельзя. И заметь, кому адресовано разрешение: не курьеру, а конкретному получателю поимённо.
Механизм: два вида запросов
Источник - тройка «схема плюс хост плюс порт». Любое отличие делает запрос
чужим: http://shop.local и https://shop.local - разные источники,
shop.local и api.shop.local - тоже.
Правило
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "https://shop.local" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Access-Control-Max-Age 86400 always;
add_header Content-Length 0;
return 204;
}
add_header Access-Control-Allow-Origin "https://shop.local" always;
proxy_pass http://app:8000;
Это тот редкий if, который безопасен: внутри только return и add_header
для того же ответа (тринадцатый раздел). Условие пишется в круглых скобках,
через пробелы; кавычки у OPTIONS не нужны. Content-Length 0 честно говорит,
что тела у ответа нет, а always ему не нужен: 204 и так в списке успешных
кодов. Access-Control-Max-Age задан в секундах: 86400 - сутки. Проверено на
стенде - preflight отвечает так (-X OPTIONS посылает запрос этим методом):
curl -si -X OPTIONS -H 'Origin: https://shop.local' http://api.shop.local/api/cart
HTTP/1.1 204 No Content
Server: nginx/1.31.5
Date: Thu, 10 Sep 2026 20:20:30 GMT
Connection: keep-alive
Access-Control-Allow-Origin: https://shop.local
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Content-Length: 0
Разбор: что делает always
add_header без always ставит заголовок только на «безопасные» коды - те же,
что в пятом разделе решали судьбу expires. Замер по одному и тому же блоку:
| код ответа | без always |
с always |
|---|---|---|
| 200 | заголовок есть | есть |
| 302 | есть | есть |
| 403 | нет | есть |
| 500 | нет | есть |
Отсюда и симптом из начала урока. Пока приложение отвечает 200, всё работает; в день, когда оно отвечает 500, фронтенд получает не «пятисотка на бэкенде», а «CORS policy», и разработчик идёт разбираться не туда.
Разбор: два разрешения хуже одного
Заголовок часто ставит и приложение, и nginx - каждый «на всякий случай».
add_header не заменяет чужой заголовок, а добавляет свой, и в ответе едут оба:
curl -sI http://api.shop.local/api/cart
HTTP/1.1 200 OK
Server: nginx/1.31.5
Date: Thu, 10 Sep 2026 20:20:30 GMT
Content-Type: application/octet-stream
Content-Length: 3
Connection: keep-alive
Access-Control-Allow-Origin: https://backend-said.example
Access-Control-Allow-Origin: https://shop.local
Браузер на такое отвечает отказом: разрешение должно быть ровно одно. Решают, кто за это отвечает, и лишнее убирают. Если хозяином назначен nginx, чужой заголовок снимают до того, как он попадёт в ответ:
proxy_hide_header Access-Control-Allow-Origin;
add_header Access-Control-Allow-Origin "https://shop.local" always;
Проверено: остаётся одна строка, и снимается даже заголовок, который бэкенд написал строчными буквами, - регистр в именах заголовков не важен.
Что ломается без этого
Звёздочка и куки. add_header Access-Control-Allow-Origin "*" always;
разрешает всем и запрещает главное: с ней браузер не пошлёт куки и
авторизацию. Сессия не работает, а в консоли ничего внятного. Для сессий
источник называют поимённо и добавляют разрешение на учётные данные:
add_header Access-Control-Allow-Origin "https://shop.local" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Vary Origin always;
Здесь источник один, и Vary поставлен на будущее. Vary: Origin обязателен,
когда источник не один: без него кеш на пути отдаст
ответ, разрешённый одному сайту, посетителю другого (одиннадцатый раздел).
Заголовок съело наследование. Правило второго раздела действует и здесь:
свой add_header в дочернем location отменяет все внешние. CORS-заголовок с
уровня server пропадает ровно там, где кто-то дописал свою строку.
Зачем это в работе
Источников почти всегда больше одного: боевой домен, админка, стенд. Перечислить их в заголовке нельзя - разрешается ровно один, - поэтому значение собирают картой:
map $http_origin $cors_origin {
default "";
https://shop.local $http_origin;
https://admin.shop.local $http_origin;
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Vary Origin always;
Пустое значение означает «заголовка не будет вовсе» - проверено на стенде:
чужому источнику в ответ уезжает только Vary. Отражать $http_origin без
карты - то есть подставлять в ответ присланный Origin как есть - нельзя: это разрешает любому сайту читать твои ответы от имени
залогиненного пользователя, а вместе с Allow-Credentials открывает приложение
целиком.
Теперь сам
Фронтенд шлёт DELETE /api/cart/42 с заголовком Authorization. В конфиге
стоит только add_header Access-Control-Allow-Origin "https://shop.local"
always;. В консоли браузера ошибка, в логе nginx нет ни одного DELETE.
Почему в логе пусто и чего не хватает?
Браузер до DELETE не дошёл. Метод непростой, значит первым ушёл OPTIONS, и
на него никто не ответил разрешением: в логе он есть, а DELETE нет. Не
хватает ветки preflight с Allow-Methods и Allow-Headers - причём
Authorization обязан быть перечислен в Allow-Headers поимённо, звёздочка там
при включённых учётных данных не работает.
Главное
CORS - правило браузера: ответ приходит целиком, но страница его не увидит без
Access-Control-Allow-Origin. Флаг always обязателен, иначе на 4xx и 5xx
заголовок исчезает и ошибка приложения маскируется под CORS. Непростые запросы
предваряются OPTIONS, которому нужен свой ответ 204 со списком методов,
заголовков и Max-Age. Разрешение должно быть ровно одно: если его ставят обе
стороны, лишнее снимают proxy_hide_header. Звёздочка запрещает куки, поэтому
для сессий источник называют поимённо, добавляют Allow-Credentials и
Vary: Origin, а несколько источников разруливают картой с белым списком.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий