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 = scheme + host + port. Любое отличие = другой 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 браузер пропускает то, что сервер явно разрешил.
Теперь о том, какие 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.
Один round-trip, минимальный оверхед.
Preflight: OPTIONS перед основным запросом
Если запрос не simple (например, PUT, DELETE, или Content-Type: application/json, или custom header), браузер сначала делает preflight — отдельный запрос методом OPTIONS, чтобы спросить разрешение.
Что триггерит 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
Пример типичного 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 с *) — нарушение спецификации, которое современные браузеры блокируют.
Антипаттерн, который встречается в туториалах: «отдавайте Allow-Origin: *, чтобы CORS не мешал». Для публичного read-only API без аутентификации — приемлемо. Для всего остального — дыра. Лучше явный whitelist origin’ов даже для dev-окружения.
Когда CORS не помогает (и не мешает)
Типичные сетевые атаки: MITM, ARP poisoning, DNS spoofing, sniffingCORS — это политика браузера. Она не действует там, где браузера нет.
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