Глава 7

CORS

Почему браузер блокирует запросы к другому домену, что такое Same-Origin Policy и как CORS позволяет серверу явно открыть доступ нужным источникам.

📖 ~18 мин Основы

Same-Origin Policy — откуда растут корни

Раньше сайты были устроены просто: один домен — одно приложение. Весь HTML, вся логика, все данные — на одном сервере. Потом появились SPA и REST API. Фронтенд отделился от бэкенда:

Архитектура
# Современное приложение — два разных домена
Фронтенд: https://app.example.com   ← статика: HTML, JS, CSS
API:       https://api.example.com   ← данные: JSON

Пользователь открывает app.example.com. JavaScript на этой странице делает запрос на api.example.com. Это наш собственный API — никакой угрозы нет. Но браузер блокирует чтение ответа. Почему?

Браузер — не просто программа для отображения страниц. Он посредник между пользователем и интернетом. Он хранит куки, сессии, токены авторизации. При каждом запросе на сайт браузер автоматически прикрепляет куки этого сайта.

🔐
Почему это опасно без защиты Ты залогинен на корпоративном портале corp.ru. В соседней вкладке открыл посторонний сайт. JavaScript на нём делает запрос на corp.ru/api/documents — браузер автоматически прикрепляет твою куку авторизации. Сервер видит валидную куку и отдаёт данные. Чужой сайт прочитал твои корпоративные документы — а ты даже не заметил.

Именно чтобы это предотвратить, браузеры ввели Same-Origin Policy (политика одного источника):

ℹ️
JavaScript может читать ответы только от того же источника, с которого загружена страница.

Это осознанное архитектурное решение. Браузер — единственный, кто одновременно знает и с какого сайта пришёл JavaScript, и какие куки у пользователя хранятся. Ни сервер, ни сеть этого не знают. Поэтому именно браузер несёт ответственность за это ограничение.

Что такое origin

Origin — это три вещи вместе: протокол, домен и порт.

https://app.example.com:443/dashboard
протокол домен порт
Origin = протокол + домен + порт

Два адреса считаются одним origin'ом только если все три части совпадают:

URL Отличие Результат
https://example.com/about только путь отличается одинаковый ✓
http://example.com другой протокол разный ✗
https://api.example.com другой домен (поддомен) разный ✗
https://example.com:8443 другой порт разный ✗
⚠️
Поддомен — это другой origin. app.example.com и api.example.com — разные origin'ы, даже если физически это один сервер и одна компания.

Что именно блокируется

Важный нюанс, который часто путают. SOP не блокирует сам запрос. Запрос уходит на сервер, сервер его обрабатывает и отвечает. Блокируется доступ JavaScript к ответу.

JavaScript на app.example.com делает запрос на api.example.com
запрос уходитсервер обрабатываетотвечает
браузер получает ответ
JavaScript хочет прочитать ответ
браузер: нет, другой origin
JavaScript видит ошибку, данные не получены
💡
В DevTools во вкладке Network запрос будет виден — он состоялся. Но в консоли будет ошибка CORS error, и данные из ответа JavaScript не получит.

CORS — управляемое исключение из SOP

SOP отлично защищает от чужих сайтов. Но он не различает «чужой враждебный сайт» и «наш собственный фронтенд на другом домене».

CORS (Cross-Origin Resource Sharing) — механизм, который позволяет серверу явно сказать браузеру: «запросы с вот этих доменов — разрешаю». По умолчанию всё заблокировано. Сервер явно открывает доступ тем, кому нужно.

Работает это через HTTP-заголовок в ответе сервера:

HTTP Response
Access-Control-Allow-Origin: https://app.example.com

Браузер видит этот заголовок, сравнивает с origin'ом страницы — и если совпадает, отдаёт ответ JavaScript-коду.

Сравнение: с заголовком и без
С заголовком — доступ открыт
GET api.example.com/users
Origin: https://app.example.com

← 200 OK
Access-Control-Allow-Origin:
  https://app.example.com
Content-Type: application/json

✓ браузер отдаёт ответ JS
Без заголовка — заблокировано
GET api.example.com/users
Origin: https://app.example.com

← 200 OK
Content-Type: application/json
[данные пришли, но...]

✗ браузер блокирует чтение
⚠️
Сервер ответил успешно — данные дошли до браузера. Но JavaScript их не получит — браузер заблокировал доступ. CORS — это ограничение браузера, а не сети.

Простые запросы и preflight

Браузер ведёт себя по-разному в зависимости от того, насколько «безопасен» запрос. Одни запросы он отправляет напрямую, перед другими сначала спрашивает разрешения.

Простой запрос — без preflight
Метод GET, HEAD или POST
Только стандартные заголовки
Content-Type: text/plain, multipart/form-data или application/x-www-form-urlencoded
Нестандартный — нужен preflight
Метод PUT, DELETE, PATCH
Заголовок Authorization
Content-Type: application/json ← самый частый случай

Такие запросы существовали задолго до CORS — обычные HTML-формы работали именно так. Поэтому для них preflight не нужен. Но почти любой REST API работает с JSON и авторизацией — значит preflight будет везде.

Простой запрос — один туда-обратно

HTTP
→ Запрос
GET /products HTTP/1.1
Origin: https://app.example.com

← Ответ
200 OK
Access-Control-Allow-Origin: https://app.example.com
[данные]

Один запрос, один ответ. Браузер проверяет Allow-Origin в ответе и либо отдаёт данные JavaScript, либо нет.

Preflight — запрос перед запросом

Если запрос «непростой» — браузер сначала отправляет preflight. Это предварительный запрос методом OPTIONS к тому же адресу: «сервер, я собираюсь сделать вот такой запрос — ты разрешишь?»

1
Preflight — браузер делает автоматически
Preflight запрос
OPTIONS /api/users/42
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization
Preflight ответ
204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 3600

2
Основной запрос — только если preflight прошёл
Основной запрос
DELETE /api/users/42
Origin: https://app.example.com
Authorization: Bearer eyJhb...

← 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Если preflight не прошёл — шаг 2 не происходит вообще. Браузер не отправит DELETE.

Зачем это нужно: DELETE — разрушительная операция. Браузер убеждается, что сервер осознанно разрешает такие запросы с этого домена, прежде чем их отправлять.

Кеширование preflight

Каждый раз отправлять два запроса вместо одного накладно. Поэтому сервер указывает сколько секунд браузер может помнить результат preflight:

Header
Access-Control-Max-Age: 3600   ← 1 час

В течение этого времени браузер не будет переспрашивать — просто отправит основной запрос напрямую. Без Max-Age preflight летит перед каждым непростым запросом.

Все CORS-заголовки

Заголовки запроса — браузер ставит автоматически

запрос
Origin
С какого домена пришёл запрос. Браузер добавляет сам при любом кросс-доменном запросе. JavaScript переопределить не может.
preflight
Access-Control-Request-Method
Только в preflight. Какой метод браузер собирается использовать в основном запросе.
preflight
Access-Control-Request-Headers
Только в preflight. Какие нестандартные заголовки будут в основном запросе.

Заголовки ответа — сервер ставит

ответ
Access-Control-Allow-Origin
Главный заголовок. Какому домену разрешён доступ.
Access-Control-Allow-Origin: https://app.example.com — конкретный домен.
Access-Control-Allow-Origin: * — любой домен. Подходит только для полностью публичных API. Несовместим с credentials.
preflight
Access-Control-Allow-Methods
Только для preflight. Какие HTTP-методы разрешены.
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH
preflight
Access-Control-Allow-Headers
Только для preflight. Какие нестандартные заголовки разрешены.
Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-ID
preflight
Access-Control-Max-Age
Только для preflight. Сколько секунд браузер кеширует результат. Без этого заголовка — preflight перед каждым непростым запросом.
Access-Control-Max-Age: 3600
ответ
Access-Control-Allow-Credentials
Разрешает отправлять куки и заголовок Authorization в кросс-доменных запросах. По умолчанию браузер их не отправляет, даже если они есть.

Нужны обе стороны: сервер ставит Allow-Credentials: true, и клиент явно указывает credentials: 'include' в fetch. Несовместим со звёздочкой в Allow-Origin — только конкретный домен.
ответ
Access-Control-Expose-Headers
Какие заголовки ответа JavaScript может прочитать. По умолчанию из кросс-доменного ответа доступен только стандартный минимум (Content-Type, Cache-Control и ещё несколько). Кастомные заголовки нужно явно разрешить.
Access-Control-Expose-Headers: X-Request-ID, X-Total-Count

Несколько доменов

Access-Control-Allow-Origin принимает только одно значение — нельзя перечислить несколько доменов через запятую. Стандартное решение: сервер сам проверяет заголовок Origin запроса и, если он в белом списке, отражает его в ответе.

nginx
# Несколько доменов через map
map $http_origin $cors_origin {
    https://app.example.com   $http_origin;
    https://admin.example.com $http_origin;
    default                   "";
}

server {
    add_header Access-Control-Allow-Origin $cors_origin always;
    add_header Vary Origin always;   # важно: кеш должен учитывать Origin
}
ℹ️
Vary: Origin — обязателен если ответ зависит от домена запроса. Без него CDN или прокси закешируют ответ с одним Allow-Origin и будут отдавать его всем, даже тем кому доступ закрыт.

Итог

Same-Origin Policy — браузер по умолчанию не даёт JavaScript читать ответы от других доменов. Это защита от чтения чужих данных через автоматически прикрепляемые куки авторизации.

CORS — механизм явного разрешения. Сервер говорит: «вот этим доменам — можно». Работает через HTTP-заголовки в ответе. Браузер проверяет их и принимает решение.

Два режима

РежимКогдаКак работает
Простой запрос GET/HEAD/POST без Authorization и без JSON Один запрос — проверка Allow-Origin в ответе
Preflight DELETE/PUT/PATCH, Authorization, Content-Type: application/json Сначала OPTIONS, потом основной запрос

На практике

ЗаголовокРоль
Access-Control-Allow-Origin Главный — кому разрешён доступ
Access-Control-Allow-Methods Разрешённые методы (для preflight)
Access-Control-Allow-Headers Разрешённые заголовки (для preflight)
Access-Control-Max-Age Кеш preflight — иначе два запроса вместо одного
Access-Control-Allow-Credentials Разрешить куки и Authorization в кросс-доменных запросах
Access-Control-Expose-Headers Какие заголовки ответа видит JavaScript
Vary: Origin Обязателен при нескольких разрешённых доменах
💡
Главное правило настройки: Access-Control-Allow-Origin ставится на одном уровне — или nginx, или бэкенд. Если оба добавят этот заголовок, браузер получит дубликат и выдаст ошибку. Используйте параметр always в nginx — иначе заголовок не добавится к ответам 4xx/5xx.