Loading...

Безопасность · 10 мин чтения

Что такое CORS: правило браузера, стоящее за каждой ошибкой cross-origin

CORS (Cross-Origin Resource Sharing) — механизм, которым сервер сообщает браузеру, каким другим сайтам можно читать его ответы. Браузер не даёт скрипту прочитать данные с другого источника, если в ответе нет заголовка Access-Control-Allow-Origin, разрешающего источник страницы. Ошибки CORS исправляют на сервере или CDN, который отвечает, а не в браузере.

Обновлено

Что такое CORS: правило браузера, стоящее за каждой ошибкой cross-origin

Политика одного источника и то, что ослабляет 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.