Политика одного источника и то, что ослабляет CORS
Источник (origin) — это три части вместе: схема, хост и порт. https://shop.example.com и https://api.example.com — разные источники, как и http://example.com и https://example.com. Путь не учитывается.
Браузеры применяют политику одного источника (same-origin policy): JavaScript одного источника может отправлять запросы к другому, но не может прочитать ответ без согласия другой стороны. То же правило распространяется на веб-шрифты с другого хоста и на чтение пикселей картинки с чужого источника, нарисованной на canvas.
Многое остаётся разрешённым без всякого согласия. Страница может встроить картинку, таблицу стилей или скрипт откуда угодно, а форма может отправить POST на любой сайт. Политика охраняет именно чтение: без неё любая открытая страница могла бы тихо обратиться к вашей почте или к API вашего банка с вашими cookie и прочитать ответ.
CORS (Cross-Origin Resource Sharing) и есть это согласие. Браузер сообщает серверу, какой источник спрашивает, в заголовке запроса Origin, а сервер отвечает заголовками Access-Control-*, в которых сказано, каким источникам можно читать ответ. Ничто в CORS не останавливает запрос на сервере: ответ от страницы скрывает браузер.
Простые запросы и preflight-запросы
Простые запросы уходят сразу. GET, HEAD или POST считается простым, если несёт только заголовки из безопасного списка CORS, например Accept, Accept-Language и Content-Language, а для тела — Content-Type вида text/plain, multipart/form-data или application/x-www-form-urlencoded. Браузер отправляет его с заголовком Origin, получает ответ и отдаёт его странице, только если Access-Control-Allow-Origin разрешает этот источник.
Всему остальному предшествует preflight: запрос OPTIONS, который спрашивает разрешения до отправки настоящего. Метод он указывает в Access-Control-Request-Method, прочие заголовки — в Access-Control-Request-Headers. Сервер должен ответить статусом 2xx, нужными заголовками Access-Control-Allow-* и без редиректа. Только после этого браузер отправляет сам запрос.
Обычный сюрприз: JSON API вызывает preflight почти на каждый вызов. Content-Type: application/json в безопасный список не входит, Authorization тоже, поэтому fetch, отправляющий JSON с bearer-токеном, платит лишним кругом запроса, если только браузер ещё не держит ответ на preflight в кэше. Для этого и нужен Access-Control-Max-Age: Chromium хранит такой ответ не дольше двух часов, Firefox — не дольше 24, а без заголовка браузер держит его пять секунд.
Preflight и ответ, который пропускает настоящий запрос
OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://shop.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 7200
Vary: Origin
Заголовки Access-Control по одному
Access-Control-Allow-Origin — тот, что решает. У него ровно одно значение: * для любого источника, один источник вроде https://shop.example.com или null. Список через запятую недопустим, а null разрешать нельзя никогда: Origin: null отправляют и iframe в песочнице, и локальные файлы.
Access-Control-Allow-Methods и Access-Control-Allow-Headers отвечают на preflight: какие методы и заголовки запроса может использовать настоящий запрос. Access-Control-Max-Age говорит, сколько секунд браузер может повторно использовать этот ответ.
Access-Control-Allow-Credentials: true позволяет странице прочитать ответ на запрос, отправленный с cookie или HTTP-аутентификацией. Правила, которые идут вместе с ним, — в следующем разделе.
Access-Control-Expose-Headers перечисляет заголовки ответа, которые может читать JavaScript страницы. Без него скрипт видит только Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified и Pragma, так что собственный X-Request-Id или ETag, нужный библиотеке загрузки, остаются невидимыми.
Со стороны запроса заголовков три, все их выставляет браузер, и код страницы подделать их не может: Origin, Access-Control-Request-Method и Access-Control-Request-Headers.
Credentials: cookie, Authorization и почему * перестаёт работать
Запрос считается запросом с credentials, если несёт cookie или HTTP-аутентификацию: fetch с credentials: 'include' или XMLHttpRequest с withCredentials = true. Для таких запросов браузер ужесточает все правила.
Access-Control-Allow-Origin должен назвать точный источник; * отклоняется. Обязателен Access-Control-Allow-Credentials: true. А * в Allow-Headers, Allow-Methods или Expose-Headers перестаёт быть подстановочным знаком и читается как заголовок, который буквально называется *.
Поскольку ответ может назвать только один источник, сервер с несколькими фронтендами держит список разрешённых, сравнивает с ним пришедший Origin и возвращает совпавший. Ответ теперь зависит от заголовка запроса, поэтому каждый ответ, в том числе вообще без CORS-заголовков, должен содержать и Vary: Origin; иначе кэш отдаст ответ одного источника другому.
Классическая ошибка — возвращать любой источник и при этом разрешать credentials. Тогда любой сайт может читать данные ваших залогиненных пользователей через их же браузеры. Сканеры безопасности отмечают это первым делом, а в OWASP Top 10 это относится к нарушенному контролю доступа. У cookie есть и собственный барьер: cookie уходит с cross-site запросом, только если она выставлена с SameSite=None и Secure.
Список разрешённых источников с эхом Origin (Node.js, Express)
const allowed = new Set([
'https://shop.example.com',
'https://admin.example.com',
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
res.vary('Origin');
if (allowed.has(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Credentials', 'true');
}
if (req.method === 'OPTIONS') {
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');
res.set('Access-Control-Max-Age', '7200');
return res.sendStatus(204);
}
next();
});
Частые ошибки CORS и что каждая означает
Сообщение в консоли называет правило, которое не выполнено. Читайте его с открытой вкладкой Network: настоящую историю обычно рассказывает код статуса упавшего запроса. Ниже — формулировки Chromium.
No 'Access-Control-Allow-Origin' header is present on the requested resource. Ответ пришёл без заголовка. Чаще всего это ответ-ошибка — 404, 500, редирект на страницу входа или 403 от файрвола, — и выдаёт его код, который до вашей обработки CORS не доходит. Сначала исправьте статус, затем убедитесь, что ответы с ошибкой тоже несут заголовок: тогда следующий сбой покажет свою настоящую причину.
The 'Access-Control-Allow-Origin' header contains multiple values '*, *', but only one is allowed. Заголовок добавляют два слоя: обычно приложение и стоящий перед ним прокси или CDN. Оставьте ровно один.
Response to preflight request doesn't pass access control check: It does not have HTTP ok status. Запрос OPTIONS получил 401, 403, 404 или 405. Обычная причина — аутентификация, которая выполняется раньше CORS: preflight никогда не несёт ваш заголовок Authorization, поэтому отвечать на него нужно до любой проверки входа.
Redirect is not allowed for a preflight request. URL перенаправляет: с http на https, на адрес с завершающим слешем, на другой хост. Обращайтесь сразу к конечному URL.
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'. Страница отправляет cookie, а сервер отвечает *. Возвращайте точный источник и добавьте Access-Control-Allow-Credentials: true либо перестаньте отправлять credentials.
Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response. Добавьте названный заголовок в Access-Control-Allow-Headers.
Источник null означает, что страница открыта через file://; откройте её с локального веб-сервера. И одно «решение», которого стоит избегать: mode: 'no-cors' глушит ошибку, отдавая коду непрозрачный ответ, который нельзя прочитать. Запрос уходит, а скрипт не получает ничего.
CORS за CDN: кэш и Vary: Origin
Кэш хранит один ответ на URL и отдаёт его всем, кто запрашивает этот URL. CORS-заголовки, меняющиеся от запроса к запросу, ломают это допущение двумя способами.
Если origin-сервер добавляет Access-Control-Allow-Origin только при наличии заголовка Origin, то, что попадёт в кэш, решает первый запрос. Если первым был обычный тег <img> или прямой заход, копия в кэше остаётся без CORS-заголовка, и каждый следующий cross-origin fetch падает, пока копия не устареет. Ошибка выглядит случайной, потому что зависит от того, кто пришёл первым.
Если origin-сервер возвращает спросивший источник, копия в кэше называет один источник, и второй сайт, читающий тот же URL, получает ответ, адресованный другому.
Чистых схем две. Для публичных файлов — шрифтов, картинок, скриптов, открытого JSON — отправляйте всем одинаковый заголовок: * или источник вашего единственного сайта. Тогда любая копия в кэше верна для любого посетителя, и доля попаданий в кэш не страдает. Если ответ обязан различаться по источнику, отправляйте Vary: Origin в каждом ответе по этому URL, чтобы кэш держал варианты раздельно. Это стоит части попаданий, поэтому ограничьтесь путями, которым это действительно нужно.
Браузеры тоже кэшируют. Картинка, загруженная сначала обычным тегом <img>, а потом запрошенная с атрибутом crossorigin, может прийти из кэша браузера без заголовка. Те же две схемы решают и этот случай.
Заголовки CORS на CDN.com.tr
Для файлов, которые отдаются через CDN, заголовок может добавить сам edge. В панели откройте Правила доставки, отредактируйте правило, которое покрывает нужный путь (например, /fonts/ или /static/), и впишите в поле Пользовательские заголовки по одному заголовку в строке в виде Имя-Заголовка значение (на странице правил с вкладками: Заголовки → Добавить заголовки ответа). Значение с пробелами берётся в двойные кавычки.
Edge добавляет эти заголовки к успешным ответам и перенаправлениям (2xx и 3xx), в том числе отданным из кэша, с момента публикации правила. Панель и edge не принимают в строке заголовка ;, { и }; CORS-заголовку они не нужны, ведь у Access-Control-Allow-Origin одно значение.
Если ваш origin-сервер уже отправляет для этих файлов заголовок Access-Control-*, выберите то же имя в списке Скрыть Заголовки этого правила. Иначе браузер получит два значения и отвергнет оба.
Две вещи остаются за origin-сервером. Первая — preflight: edge передаёт запросы OPTIONS вашему серверу и никогда их не кэширует, так что API, которому нужен preflight, отвечает на него сам. Обычным GET-запросам шрифтов, картинок и открытого JSON preflight не нужен, и им достаточно заголовка из правила. Вторая — ответы, зависящие от источника: вернуть один из нескольких разрешённых источников, как требуют запросы с credentials, решает ваше приложение. Edge держит такие варианты раздельно, когда origin-сервер отправляет Vary: Origin, если только правило не настроено игнорировать Vary. В правиле с оптимизацией изображений картинки JPEG и PNG отдаются через оптимизатор, который ставит собственный Vary, поэтому картинкам задайте фиксированный заголовок.
Если для сайта включён WAF, OPTIONS по умолчанию входит в разрешённые методы; PUT, PATCH и DELETE нужно отметить в Разрешённые HTTP-методы. Запрос, который отклонил edge, получает 403 без CORS-заголовков, и браузер сообщает о нём как об ошибке CORS. Заголовок X-MT-Blocked-By: cdn-edge-security в этом 403 говорит, что «нет» сказал edge, а не ваше приложение.
Пользовательские заголовки в правиле для /fonts/ или /static/
Access-Control-Allow-Origin *
Access-Control-Expose-Headers "Content-Length, ETag"
CORS для бакетов объектного хранилища
На файлы, которые браузер загружает прямо в бакет, например через presigned PUT из веб-приложения, отвечает сервис хранения, а не ваше приложение, поэтому бакету нужны собственные правила CORS.
Объектное хранилище CDN.com.tr принимает их стандартным вызовом S3 PutBucketCors на https://s3.cdn.com.tr и само отвечает на preflight-запросы браузера. Подойдёт любой S3-инструмент, умеющий задавать CORS бакета; с AWS CLI это одна команда и небольшой JSON-файл.
Для всего, что пишет данные, перечисляйте точные источники вместо * и открывайте ETag: браузерные библиотеки загрузки читают его, чтобы завершить multipart-загрузку. Публичным бакет CORS тоже не делает. Приватный бакет по-прежнему отвечает 403 на анонимное чтение; CORS решает лишь, может ли страница прочитать ответ, который ей и так разрешено было получить.
CORS бакета через AWS CLI
# cors.json
{
"CORSRules": [{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}]
}
aws s3api put-bucket-cors --bucket my-bucket \
--cors-configuration file://cors.json \
--endpoint-url https://s3.cdn.com.tr
aws s3api get-bucket-cors --bucket my-bucket \
--endpoint-url https://s3.cdn.com.tr
Проверка CORS из командной строки
curl не применяет CORS, и именно поэтому он подходит, чтобы увидеть, что на самом деле отвечает сервер. Отправьте заголовок Origin сами и прочитайте вернувшиеся заголовки Access-Control-*; для preflight отправьте OPTIONS с двумя заголовками, которые добавил бы браузер.
Проверьте три вещи: статус 2xx у preflight, ровно один Access-Control-Allow-Origin и Vary: Origin везде, где значение зависит от источника. Затем повторите запрос через CDN и сравните. На CDN.com.tr заголовок X-Proxy-Cache-MT показывает, пришёл ли ответ из кэша; если ответ из кэша отличается от первого, работает описанная выше проблема с кэшем.
# a simple request: what does the edge answer for this origin?
curl -sI https://cdn.example.com/fonts/inter.woff2 \
-H 'Origin: https://www.example.com' \
| grep -i -E 'access-control|vary|x-proxy-cache'
# a preflight, as the browser would send it
curl -si -X OPTIONS https://api.example.com/v1/orders \
-H 'Origin: https://shop.example.com' \
-H 'Access-Control-Request-Method: PUT' \
-H 'Access-Control-Request-Headers: authorization, content-type'
Частые вопросы о CORS
Защищает ли CORS мой API?
Нет. CORS защищает браузеры ваших пользователей от страниц, которые пытаются читать данные от их имени. Против скрипта, сервера или curl, обращающихся к API напрямую, он ничего не делает, а простые запросы доходят до сервера, даже если браузер потом скроет ответ. Собственная аутентификация и авторизация API нужны по-прежнему.
Безопасен ли Access-Control-Allow-Origin: *?
Для публичных ресурсов, которым не нужен вход, — шрифтов, картинок, скриптов, открытого JSON — да: их и так может скачать кто угодно. Для всего, что зависит от cookie или сессии пользователя, он не годится, и браузеры отказываются сочетать его с credentials.
Как разрешить больше одного источника?
Не списком: у заголовка одно значение. Держите на сервере список разрешённых, возвращайте Origin запроса, если он в списке, и отправляйте Vary: Origin в каждом ответе, чтобы кэши хранили ответы раздельно.
Почему запрос работает в Postman или curl, но не в браузере?
Потому что CORS применяют только браузеры. Postman и curl отправляют запрос и показывают всё, что вернулось; браузер же проверяет заголовки Access-Control-*, прежде чем показать ответ вашему JavaScript.
Почему ошибка CORS появляется лишь иногда?
Обычно из-за кэша. Если ответ меняется в зависимости от заголовка Origin, но не содержит Vary: Origin, запрос, первым попавший в кэш, решает, что получат все остальные. Для публичных файлов отправляйте фиксированный заголовок, а там, где ответ должен различаться, — Vary: Origin.
Как включить CORS на CDN.com.tr?
Для файлов, которые отдаёт CDN, добавьте Access-Control-Allow-Origin в Пользовательские заголовки правила доставки; ответы на preflight для вашего API и ответы, зависящие от источника, даёт origin-сервер. Для бакетов объектного хранилища задайте правила CORS вызовом S3 PutBucketCors на https://s3.cdn.com.tr.