Learning Platform
Глоссарий Troubleshooting
Урок 04.03 · 22 мин
Начальный
CORSSame-Origin PolicyPreflightOPTIONSBrowser security

CORS: как браузер защищает один origin от другого

CORS (Cross-Origin Resource Sharing) — самый частый источник недоумения у тех, кто впервые делает frontend, который ходит в API. «Запрос из консоли браузера падает с непонятной ошибкой про CORS, а из curl всё работает». Это не баг — это фича браузера. И понять, почему она существует, важно прежде, чем учить, какие headers выставлять.

CORS — это не свойство сервера и не свойство сети. Это политика браузера. В этом уроке разберём Same-Origin Policy (что это за «origin» вообще), CORS как разрешённое исключение, разницу между simple requests и preflight, и когда CORS не помогает.


Same-Origin Policy: фундамент безопасности браузера

Cookies, sessions, CORS: иллюзия состояния поверх stateless HTTP

Когда вы открываете https://bank.com, браузер скачивает HTML, JS, CSS. Теперь представьте: на этой же странице открыта вкладка с https://evil.com. Что должно произойти, если JS из evil.com попытается сделать fetch("https://bank.com/api/balance") и прочитать ответ?

Если бы такое было разрешено, evil.com мог бы:

  • Сделать запрос на bank.com, используя cookies жертвы (которые браузер автоматически прикрепляет).
  • Получить баланс, операции, что угодно.
  • Отправить эти данные себе.

Это и есть базовая угроза, против которой работает Same-Origin Policy (SOP). Браузер по умолчанию запрещает JS-коду читать ответы с другого origin’а.

Что такое origin

Origin = scheme + host + port. Любое отличие = другой origin

https://api.example.com:443Origin = scheme (https) + host (api.example.com) + port (443, default для https)
https://api.example.com:443/usersТОТ ЖЕ origin. Path не входит в определение origin
https://api.example.com:8080ДРУГОЙ origin. Порт другой (8080 vs 443)
http://api.example.comДРУГОЙ origin. Scheme другой (http vs https)
https://www.example.comДРУГОЙ origin. Host другой (www.example.com vs api.example.com)
https://example.comДРУГОЙ origin. Host без поддомена тоже считается другим. example.com и www.example.com -- разные origin

Что именно SOP запрещает:

  • Чтение body ответа через JS, если запрос ушёл на другой origin.
  • Чтение cookie/localStorage другого origin (всегда, без исключений).
  • Чтение DOM документа из другого origin (iframe).

Что SOP разрешает:

  • Отправку запроса на другой origin (cross-origin запросы технически уходят) — но JS не может прочитать ответ.
  • Загрузку ресурсов через <img src=>, <script src=>, <link rel=stylesheet> — это исторические исключения, от которых нельзя избавиться (старые сайты на них держатся).
  • Submit формы на другой origin (POST через <form>) — тоже legacy. Но JS ответ не прочитает.

Эти исключения и создают пространство атак: например, CSRF, где <form action="https://bank.com/transfer"> отправляет запрос с cookies жертвы — браузер пошлёт его, сервер обработает, но JS atttacker’а не сможет прочитать результат. Поэтому атака on success / on failure часто маскируется как side-effect.


CORS — это разрешённое исключение

CORS — механизм, через который сервер может явно разрешить браузеру дать JS прочитать ответ с другого origin. Сервер выставляет специальные headers, и браузер на их основании разрешает или запрещает чтение.

Это важно понять: CORS — это разрешение от сервера, не запрет. Без CORS браузер запрещает по умолчанию. С CORS-headers браузер пропускает то, что сервер явно разрешил.

CORS = сервер говорит браузеру 'разрешаю'
frontend.comOrigin frontend-приложения. Здесь крутится JS, который хочет fetch на api.example.com
fetch
api.example.comCross-origin сервер. Может вернуть Access-Control-Allow-Origin: https://frontend.com -- браузер разрешит чтение ответа
Без CORS-headersСервер ответил 200 OK с body, но не выставил Access-Control-Allow-Origin. Браузер: запрос ушёл, ответ пришёл, но JS-у я его не отдам. CORS error в консоли
CORS errorВ консоли браузера: 'CORS policy: No Access-Control-Allow-Origin header is present'. Запрос на сервер прошёл, ответ получен, JS его не видит
С CORS-headersСервер вернул Access-Control-Allow-Origin: https://frontend.com. Браузер видит, что текущий origin (frontend.com) разрешён, и пропускает ответ к JS
JS reads responseJS получает доступ к response.json() и т.д.

Теперь о том, какие headers и когда нужны.


Simple requests: один запрос, без preflight

Часть запросов считаются «безопасными» — для них браузер просто шлёт запрос с дополнительным header Origin: https://frontend.com и смотрит ответ. Если ответ содержит Access-Control-Allow-Origin с подходящим origin (или *) — JS получает доступ.

Запрос считается simple (CORS-safelisted) если выполнены ВСЕ условия:

  • Метод: GET, HEAD или POST.
  • Headers только из safelist: Accept, Accept-Language, Content-Language, Content-Type (с ограничениями), некоторые другие.
  • Content-Type только: application/x-www-form-urlencoded, multipart/form-data, text/plain. Не application/json!
  • Никаких ReadableStream в body.
  • Никаких event listener’ов на XMLHttpRequest.upload.
Simple cross-origin request
Browser (frontend.com)
API server (api.example.com)
GET /data + Origin: https://frontend.com200 OK + Access-Control-Allow-Origin: https://frontend.com + body

Один round-trip, минимальный оверхед.


Preflight: OPTIONS перед основным запросом

Если запрос не simple (например, PUT, DELETE, или Content-Type: application/json, или custom header), браузер сначала делает preflight — отдельный запрос методом OPTIONS, чтобы спросить разрешение.

Preflight: OPTIONS, потом основной запрос
Browser
API server
OPTIONS /users/1 + Origin + Access-Control-Request-Method: PUT + Access-Control-Request-Headers: Content-Type204 No Content + Access-Control-Allow-Methods: PUT,POST,GET + Access-Control-Allow-Headers: Content-Type + Access-Control-Max-Age: 600PUT /users/1 + Content-Type: application/json + body200 OK + Access-Control-Allow-Origin + body

Что триггерит preflight:

  • Метод не GET/HEAD/POST (PUT, DELETE, PATCH, etc.).
  • Content-Type не из safelist (например, application/json).
  • Любой custom header (Authorization, X-Custom-Header, etc.).

В результате — преобладающее большинство современных API-запросов из браузера требуют preflight. Это влияет на латентность: каждый PUT/DELETE — это два round-trip’а, если preflight не закэширован.

Access-Control-Max-Age: 600 говорит браузеру: «закэшируй разрешения на 10 минут, не повторяй preflight для этого endpoint в течение этого времени». Полезно для производительности.


Полный набор Access-Control-* headers

Headers сервера для CORS
Access-Control-Allow-OriginКакой origin разрешён. Конкретный: https://frontend.com -- для credentials. Или *: разрешено всем (но не работает с credentials). Браузер сравнивает строго: точное совпадение со значением Origin из запроса
Access-Control-Allow-MethodsСписок разрешённых методов. Используется в ответе на preflight OPTIONS. Например: GET, POST, PUT, DELETE
Access-Control-Allow-HeadersСписок разрешённых custom headers. Например: Content-Type, Authorization, X-API-Version. Используется в ответе на preflight
Access-Control-Allow-Credentialstrue означает: разрешить отправку cookies/Authorization. Без этого header'a браузер по умолчанию НЕ отправляет credentials cross-origin. Если true -- Allow-Origin не может быть *, должен быть конкретный origin
Access-Control-Expose-HeadersКакие response headers JS может прочитать через response.headers.get(). По умолчанию JS видит только safelist headers (Cache-Control, Content-Type, Expires, Last-Modified, Pragma). Custom headers (X-Total-Count, X-Rate-Limit) -- только если они в Expose
Access-Control-Max-AgeСколько секунд браузер может кэшировать ответ на preflight. Уменьшает повторные OPTIONS-запросы. Типичные значения: 600 (10 мин), 86400 (24 часа). Слишком большие значения опасны при изменении CORS-политики

Пример типичного preflight-ответа:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://frontend.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin

Обратите внимание на Vary: Origin. Если ваш сервер выдаёт разный Allow-Origin в зависимости от Origin запроса (динамически выбирает из whitelist) — обязательно ставьте Vary: Origin, иначе CDN/прокси закэширует ответ для одного origin и отдаст другому.


Wildcard и его ограничения

Access-Control-Allow-Origin: * означает «разрешено для любого origin’а». Удобно для полностью публичных API (без аутентификации).

Но есть критическое ограничение: wildcard несовместим с credentials. Если сервер хочет, чтобы браузер отправлял cookies/Authorization (Access-Control-Allow-Credentials: true), он должен указать конкретный origin, не *.

Это ограничение специально: иначе любой сайт мог бы делать запросы с cookies жертвы и читать ответы, эффективно обходя CSRF-защиту.

Правильный паттерн для API с cookies:

# Сервер: разрешённые origin лежат в whitelist
ALLOWED_ORIGINS = {"https://frontend.example.com", "https://staging.example.com"}

origin = request.headers.get("Origin")
if origin in ALLOWED_ORIGINS:
    response.headers["Access-Control-Allow-Origin"] = origin
    response.headers["Access-Control-Allow-Credentials"] = "true"
    response.headers["Vary"] = "Origin"
# Если origin не в whitelist -- не выставляем header, браузер заблокирует

Никогда не делайте Access-Control-Allow-Origin: * для API, который использует cookies или Authorization. Это либо не сработает (браузер откажется), либо (если включено Allow-Credentials: true с *) — нарушение спецификации, которое современные браузеры блокируют.

WARNING

Антипаттерн, который встречается в туториалах: «отдавайте Allow-Origin: *, чтобы CORS не мешал». Для публичного read-only API без аутентификации — приемлемо. Для всего остального — дыра. Лучше явный whitelist origin’ов даже для dev-окружения.


Когда CORS не помогает (и не мешает)

Типичные сетевые атаки: MITM, ARP poisoning, DNS spoofing, sniffing

CORS — это политика браузера. Она не действует там, где браузера нет.

Когда CORS не действует
Server-to-serverBackend Python-скрипт делает requests.get() -- никакого CORS. Origin header не отправляется, ответ полностью читается. CORS существует только в браузере
curl, postmanАналогично -- CLI-инструменты не реализуют SOP. Запрос уходит, ответ читается. Если ваш API падает в браузере, но работает в curl -- это CORS, а не что-то с запросом
Mobile native appsiOS/Android приложения работают через native HTTP клиенты, не через браузерный движок. SOP не действует, CORS-headers сервер можно не выставлять
Server-side renderingЕсли ваш Next.js/Nuxt делает fetch на бэке во время SSR -- это server-to-server, не браузер. CORS не нужен
CORS НЕ защищает API отCORS -- это защита БРАУЗЕРА от чтения cross-origin ответа. Это НЕ защита API от unauthorized access. Любой curl с правильным API-ключом достучится. Authentication/authorization -- это отдельный слой
CORS НЕ предотвращает запросыЗапрос всё равно уходит на сервер (даже без preflight для simple requests). CORS блокирует только ЧТЕНИЕ ОТВЕТА в JS. Side effects (insert в БД, отправка email) уже произошли

Mental model: CORS — это защита пользователя браузера от того, чтобы вредоносный сайт не воровал данные с его аккаунта на других сайтах. Это не защита API от плохих клиентов. Не путайте CORS с rate limiting, authentication, authorization — это совершенно разные слои.


Типичные CORS-ошибки и их диагностика

Access to fetch at 'https://api.example.com/data' from origin 'https://frontend.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.

Сервер не выставил Access-Control-Allow-Origin. Решение: добавить header на сервере.

... has been blocked by CORS policy: Response to preflight request doesn't pass
access control check: It does not have HTTP ok status.

Preflight (OPTIONS) вернул не 2xx. Часто 405 Method Not Allowed — сервер не настроен принимать OPTIONS. Решение: добавить обработчик OPTIONS, возвращающий 204 + CORS-headers.

... has been blocked by CORS policy: The 'Access-Control-Allow-Origin' header
contains the invalid value '*' when the credentials flag is true.

Allow-Credentials: true несовместим с wildcard. Решение: вернуть конкретный origin вместо *.

... Request header field X-Custom is not allowed by Access-Control-Allow-Headers
in preflight response.

Клиент шлёт custom header, который не в Allow-Headers. Решение: добавить X-Custom в Allow-Headers ответа на OPTIONS.


Попробуй сам

Проверьте CORS-поведение реальных API.

# 1. Запрос без Origin (curl) -- CORS не активен
curl -i https://api.github.com/users/octocat | head -20

# 2. Имитируем браузерный запрос с Origin
curl -i -H "Origin: https://example.com" https://api.github.com/users/octocat | grep -i access-control
# Access-Control-Allow-Origin: *
# Access-Control-Expose-Headers: ETag, ...

# 3. Preflight на API, который не разрешает PUT с другого origin
curl -i -X OPTIONS \
  -H "Origin: https://example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: Content-Type" \
  https://api.github.com/repos/octocat/hello-world

# 4. Простой Flask-сервер с CORS (для теста локально)
python3 - <<'PY'
from flask import Flask, jsonify
from flask_cors import CORS
app = Flask(__name__)
CORS(app, origins=["http://localhost:3000"], supports_credentials=True)

@app.route("/api/data")
def data():
    return jsonify({"ok": True})

# Запустить: app.run(port=5000)
print("Add CORS to Flask app -- see flask-cors docs")
PY

Откройте chrome://inspect или DevTools на любом сайте, выполните в консоли:

// Cross-origin GET
fetch('https://api.github.com/users/octocat')
  .then(r => r.json())
  .then(console.log);
// Работает -- GitHub отдаёт ACAO: *

// Cross-origin POST с JSON
fetch('https://httpbin.org/post', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({hello: 'world'})
}).then(r => r.json()).then(console.log);
// httpbin отдаёт CORS-headers, тоже работает

// Сравните Network tab -- увидите OPTIONS перед POST

Проверка знанийKnowledge check
Frontend на https://app.example.com делает fetch('https://api.example.com/users', {method: 'POST', headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer xxx'}, credentials: 'include', body: '{...}'}). Подробно: какие запросы уйдут к серверу (по порядку), какие headers сервер должен вернуть на каждый, что произойдёт, если сервер отдаст Access-Control-Allow-Origin: *?
ОтветAnswer
По шагам. (1) Запрос НЕ simple -- три причины: метод POST с Content-Type: application/json (не из safelist), наличие custom header Authorization, credentials: 'include'. Браузер делает preflight. (2) Preflight: OPTIONS https://api.example.com/users с headers: Origin: https://app.example.com, Access-Control-Request-Method: POST, Access-Control-Request-Headers: authorization,content-type. (3) Сервер должен ответить 2xx (обычно 204) с headers: Access-Control-Allow-Origin: https://app.example.com (КОНКРЕТНЫЙ origin, не *), Access-Control-Allow-Methods: POST, Access-Control-Allow-Headers: Content-Type,Authorization, Access-Control-Allow-Credentials: true, желательно Access-Control-Max-Age: 600 для кэша preflight, и Vary: Origin. (4) Если preflight прошёл -- браузер шлёт реальный POST с Authorization: Bearer xxx и cookies (из-за credentials: include). (5) Сервер возвращает 200 OK с теми же CORS-headers (Allow-Origin: конкретный, Allow-Credentials: true) + body. Если сервер вернёт Access-Control-Allow-Origin: * вместо конкретного origin: браузер заблокирует ОБА запроса с ошибкой 'The 'Access-Control-Allow-Origin' header contains the invalid value * when the credentials flag is true'. Wildcard несовместим с credentials по спецификации -- иначе любой сайт мог бы воровать данные с любого аккаунта. Правильный паттерн: сервер должен динамически возвращать значение Origin запроса (из whitelist) с Vary: Origin для корректного кэширования.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Из консоли браузера на https://app.com fetch('https://api.com/data') падает с CORS error. Из терминала curl https://api.com/data возвращает 200 OK с JSON. Что значит 'CORS не работает только в браузере'?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 3