# теория · шаг 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;
return 204;
}
add_header Access-Control-Allow-Origin "https://shop.local" always;
proxy_pass http://app:8000;
Это тот редкий if, который безопасен: внутри только return и add_header
для того же ответа (тринадцатый раздел). Условие пишется в круглых скобках,
через пробелы; кавычки у OPTIONS не нужны. Content-Length руками не ставят:
в ответе 204 этот заголовок стандарт HTTP запрещает вовсе, и nginx сам его не
шлёт. Добавленный через add_header, он уйдёт клиенту - замер на 1.31.8, - и
ответ формально станет неверным. Access-Control-Max-Age задан в секундах:
86400 - сутки. Проверено на стенде - preflight отвечает так (-X OPTIONS
посылает запрос этим методом):
curl -si -X OPTIONS http://api.shop.local/api/cart \
-H 'Origin: https://shop.local' \
-H 'Access-Control-Request-Method: PUT' \
-H 'Access-Control-Request-Headers: Authorization, Content-Type'
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
Разбор: что делает 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; разрешает всем и ломает главное: со звёздочкой браузер НЕ ОТДАЁТ ОТВЕТ
скрипту, если запрос шёл с учётными данными (credentials: "include"). Сессия
не работает, а в консоли - The value of the 'Access-Control-Allow-Origin'
header in the response must not be the wildcard '*' when the request's
credentials mode is 'include'.
Важная деталь, которую обычно понимают неверно: куки при этом УХОДЯТ на сервер. Замер в настоящем браузере - страница на одном порту, API на другом, в ответе API звёздочка:
8091 GET /api/ origin="http://127.0.0.1:8090" cookie="sid=secret123"
ERR: Failed to fetch (blocked by CORS policy)
Запрос дошёл, кука дошла, сервер его обработал - браузер спрятал только ОТВЕТ.
Отсюда практический вывод: CORS ограничивает чтение ответа скриптом и не
защищает от самого запроса. Оговорка одна: это про простой запрос, который
браузер отправляет без предварительной проверки. Непростой (DELETE, свой
заголовок) сначала идёт OPTIONS-ом, и если тот не получил разрешения,
основной запрос не уходит вовсе. Если у запроса есть побочный эффект, от чужих
сайтов его закрывают не CORS, а проверкой источника на сервере и CSRF-токеном.
Для сессий источник называют поимённо и добавляют разрешение на учётные данные:
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 управляет доступом браузерного кода к ответу и не заменяет авторизацию или защиту от CSRF. Проверяй заголовки на успешных ответах, ошибках и preflight. При запросах с учётными данными разрешай конкретные источники и убедись, что приложение и nginx не добавляют противоречащие заголовки.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий