1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6

# теория · шаг 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 с данными. Разрешающий заголовок в конфиге стоит:

/etc/nginx/conf.d/api.shop.local.confhttp › server › location /api/
add_header Access-Control-Allow-Origin "https://shop.local";

Почему браузер всё равно отказывает?

Потому что в этот момент API отвечает не 200. Ответ 500 или 403 уезжает без этого заголовка: add_header без always работает только на успешных кодах и редиректах. Разработчик ищет ошибку в настройке доступа, а искать надо ошибку приложения, которую CORS просто накрыл собой.

На что это похоже

Курьер привозит документы из другой организации и оставляет их на вахте. Вахтёр отдаёт конверт получателю только при письменном разрешении отправителя: «этому человеку показать можно». Разрешение выдаёт именно отправитель, а не получатель и не курьер.

Нет бумажки - конверт лежит на вахте нераспечатанным. Он приехал, он целый, внутри всё нужное, а прочитать нельзя. И заметь, кому адресовано разрешение: не курьеру, а конкретному получателю поимённо.

Механизм: два вида запросов

простой запрос: GET, HEAD, простой POST страница GET /api/cart сервер ответ + разрешение непростой: PUT, DELETE, свой заголовок страница OPTIONS /api/cart сервер 204 + что можно PUT /api/cart и только теперь сам запрос Access-Control-Max-Age говорит, сколько помнить разрешение, иначе OPTIONS ходит перед каждым запросом и удваивает их число
Preflight - отдельный запрос методом OPTIONS. Он идёт до основного и должен получить свой ответ.

Источник - тройка «схема плюс хост плюс порт». Любое отличие делает запрос чужим: http://shop.local и https://shop.local - разные источники, shop.local и api.shop.local - тоже.

Правило

/etc/nginx/conf.d/api.shop.local.confhttp › server › location /api/
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'
↳  выводответ на preflight для PUT с 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
↳  вывод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, чужой заголовок снимают до того, как он попадёт в ответ:

/etc/nginx/conf.d/api.shop.local.confhttp › server › location /api/
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 звёздочка:

↳  выводлог nginx на стороне API
8091 GET /api/ origin="http://127.0.0.1:8090" cookie="sid=secret123"
↳  выводчто увидел JavaScript
ERR: Failed to fetch   (blocked by CORS policy)

Запрос дошёл, кука дошла, сервер его обработал - браузер спрятал только ОТВЕТ. Отсюда практический вывод: CORS ограничивает чтение ответа скриптом и не защищает от самого запроса. Оговорка одна: это про простой запрос, который браузер отправляет без предварительной проверки. Непростой (DELETE, свой заголовок) сначала идёт OPTIONS-ом, и если тот не получил разрешения, основной запрос не уходит вовсе. Если у запроса есть побочный эффект, от чужих сайтов его закрывают не CORS, а проверкой источника на сервере и CSRF-токеном.

Для сессий источник называют поимённо и добавляют разрешение на учётные данные:

/etc/nginx/conf.d/api.shop.local.confhttp › server › location /api/
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 пропадает ровно там, где кто-то дописал свою строку.

Зачем это в работе

Источников почти всегда больше одного: боевой домен, админка, стенд. Перечислить их в заголовке нельзя - разрешается ровно один, - поэтому значение собирают картой:

/etc/nginx/nginx.confhttp
map $http_origin $cors_origin {
    default                  "";
    https://shop.local       $http_origin;
    https://admin.shop.local $http_origin;
}
/etc/nginx/conf.d/api.shop.local.confhttp › server › location /api/
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 не добавляют противоречащие заголовки.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.