Скрипт витрины вызывает API личного кабинета, консоль браузера выбрасывает красную ошибку про Access-Control-Allow-Origin, и разработчик за десять минут превращает осторожный механизм в дыру: звёздочка в ответе, копия чужого заголовка, разрешение учётных данных. Ошибка уходит, а вместе с ней и граница, которую браузер честно держал. Механизм CORS выглядит капризом браузера ровно до понимания, что именно он охраняет: чужая страница не должна свободно читать ответы сервера от имени посетителя. Разбор устройства и политики складывается из правил происхождения, предпроверок, учётных данных и списка ошибок, каждая из которых однажды дорого обошлась чьему-то проекту.
Правило одного origin и из чего складывается происхождение
Браузер считает две страницы одинаковыми, только когда совпадают три части адреса: схема, хост и порт. страница на защищённой схеме и та же страница на обычной - разные происхождения, app.example.com и api.example.com - из-за хоста, а example.com:8080 и example.com:8443 - из-за порта. Правило одного происхождения запрещает скрипту с одной страницы читать ответы серверов с другим происхождением: ресурс вежливо загрузить можно, а вот прочитать его содержимое чужим кодом нельзя.
Правило появилось задолго до современных интерфейсов и защищает от подглядывания. Но легальные межсайтовые вызовы нужны всем: витрина зовёт API на поддомене, медиатека живёт на отдельном хранилище. Выпускным клапаном стал механизм CORS: сервер объявляет браузеру разрешённые происхождения специальными заголовками ответа, браузер проверяет их и либо открывает ответ скрипту, либо блокирует. Механизм доступен во всех браузерах с июля 2015 года, а актуальную форму задаёт живой стандарт Fetch после устаревания первой редакции спецификации.
Механика CORS от заголовка Origin до ответа сервера
При межсайтовом запросе браузер добавляет заголовок Origin с адресом страницы-инициатора. Сервер отвечает заголовком Access-Control-Allow-Origin: точным адресом клиента или звёздочкой. Если заголовка нет или значение не совпало, браузер не отдаст ответ скрипту; сам простой запрос при этом успевает дойти до сервера и выполниться, тонкость важна для понимания защиты. Подробности ошибки JavaScript получить не может, из соображений безопасности остаётся только сам факт ошибки. Код ответа при этом может быть любым: сервер отработал, браузер лишь прячет тело от скрипта, и диагностика сводится к чтению сетевого журнала разработчика. Для чтения скриптом нестандартных заголовков ответа вроде X-Request-Id нужен ещё Access-Control-Expose-Headers с их перечнем, по умолчанию скрипту видна лишь безопасная база: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified и Pragma.
Часть запросов уходит без предварительной договорённости. Условия простого запроса: метод GET, HEAD или POST; из заголовков вручную заданы только безопасные Accept, Accept-Language, Content-Language, Content-Type и Range; для Content-Type разрешены значения application/x-www-form-urlencoded, multipart/form-data и text/plain; Range допускается ровно с одним интервалом вида bytes=127-255. Логика историческая: HTML-форма умела отправлять такие запросы на любой адрес до всяких скриптов, и серверы обязаны были их переживать. Как только скрипт заявляет JSON, запрос перестаёт быть простым:
const response = await fetch("https://api.example.com/orders", {
headers: { "Content-Type": "application/json" },
credentials: "include"
});
Значение application/json не входит в разрешённый список, и браузер уходит за предварительным разрешением.
Предпроверка OPTIONS и когда браузер отправляет лишний запрос
Перед небезопасным методом или нестандартным заголовком браузер посылает OPTIONS с описанием намерений: Access-Control-Request-Method называет метод настоящего запроса, Access-Control-Request-Headers перечисляет заголовки. Диалог выглядит так:
OPTIONS /api/orders HTTP/1.1
Host: api.example.com
Origin: https://app.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://app.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-Max-Age, дефолт - пять секунд. Firefox держит кэш максимум сутки (86400 секунд), Chromium до 76 версии ограничивал десятью минутами, а начиная с 76 отводит два часа, то есть 7200 секунд. Огромные значения в заголовке бессмысленны: браузер срежет их до своего потолка.
Провал предпроверки читается в сетевом журнале как OPTIONS с неуспешным статусом или ответом без нужных заголовков. Сервер обязан ответить успешно, чаще всего 200 или 204, и вернуть разрешение на заявленный метод и каждый заявленный заголовок, иначе настоящий запрос не начнётся. Популярные каркасы закрывают это автоматически: в Spring есть аннотация CrossOrigin, в ASP.NET - своя прослойка, но десяток собственных строк остаётся самым прозрачным вариантом.
Учётные данные cookies и ограничение подстановочного знака
Файлы cookie делают межсайтовые вызовы опасными: с ними запрос уходит от имени авторизованного человека. Для этого у fetch есть режим credentials: include, и к серверу предъявляются повышенные требования. Сервер обязан вернуть заголовок Access-Control-Allow-Credentials: true, а Access-Control-Allow-Origin обязан быть точным адресом: звёздочка с учётными данными запрещена, и комбинация просто ломается ошибкой. Обхода тут нет и не должно быть.
Работают и сторонние правила файлов cookie: атрибут SameSite решает, поедет ли кука за межсайтовым запросом. Strict и Lax удерживают файлы cookie в своих границах, значение None требует атрибута Secure, а мягкий вариант Lax по умолчанию пропускает ещё и POST в первые две минуты после установки куки. Настройка CORS ничего из этого не отменяет, браузерные политики применяются поверх серверных заголовков. И если сервер отдаёт разным клиентам разные значения Access-Control-Allow-Origin, к ответу добавляют Vary: Origin, иначе промежуточный кэш выдаст чужому посетителю чужое разрешение.
Альтернатива файлам cookie - токен в заголовке Authorization с приставкой Bearer. Такой запрос не бывает простым: заголовок не входит в безопасный список, и каждая операция начинается с предпроверки. Зато исчезает привязка к SameSite, а ключ доступа живёт в хранилище скрипта, а не в автоматических пересылках браузера. Права при этом проверяются на сервере отдельной логикой: CORS ничего не говорит о правах, он только про доставку ответов.
Опасные ошибки конфигурации от отражения origin до значения null
Ошибка первая и любимая: отражение. Сервер копирует заголовок Origin из запроса в ответ и добавляет разрешение учётных данных. Ошибка предпроверки уходит, а вместе с ней и защита: любая страница в интернете получает право читать ответы от имени посетителя, включая личные данные. Проверка по подстроке не спасает: список example.com найдёт attackerexample.com, а сравнение по суффиксу пропустит адрес вида example.com.attacker.com. Сверять надо строгим равенством.
// небезопасно: заголовок отражает любой origin
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
Ошибка вторая - значение null. Песочницы iframe, локально открытые страницы и перенаправления из data-адресов присылают Origin: null, и уставший от отладки разработчик вписывает null в разрешённый список, открывая его всем приватным сценариям разом. Ошибка третья - непонимание границ: CORS не защита сервера. Небраузерные клиенты вообще не спрашивают разрешения, простые запросы доходят до сервера без предпроверки, поэтому права доступа и защита от подделки запросов обязаны жить на сервере собственной жизнью, а не в заголовках для браузера.
Отдельно стоит случай честной звёздочки: для публичного открытого API без авторизации, картинок и шрифтов ответ со звёздочкой без учётных данных легален и безопасен, потому что читать открытые данные не запрещено никому. Проблемы начинаются, когда звёздочку ставят на адрес, различающий посетителей. Ещё одна коварность - промежуточные слои: обратный прокси или CDN иногда срезают заголовки CORS, поэтому конфигурацию проверяют снаружи по реальным заголовкам ответа.
Безопасная настройка CORS на Express и Nginx с белым списком
Здоровая конфигурация скучна: короткий белый список, точное отражение совпавшего адреса, кэш предпроверки и аккуратный ответ на OPTIONS. На Express 5.3.0 это умещается в маленькую прослойку, пакет cors 2.8.6 умеет то же через опцию origin:
const ALLOWED_ORIGINS = ["https://app.example.com", "https://admin.example.com"];
app.use((req, res, next) => {
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Max-Age", "7200");
}
if (req.method === "OPTIONS") return res.sendStatus(204);
next();
});
Для Nginx список адресов удобнее разложить в map и не повторять условия:
map $http_origin $cors_origin {
default "";
"https://app.example.com" "https://app.example.com";
"https://admin.example.com" "https://admin.example.com";
}
server {
listen 443 ssl;
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Vary "Origin" always;
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Access-Control-Max-Age "7200";
return 204;
}
proxy_pass http://127.0.0.1:3000;
}
}
Чистая настройка укладывается в контрольный перечень:
- Сверять origin строгим равенством с белым списком, а не подстрокой или суффиксом;
- Возвращать Vary Origin вместе с точным значением адреса клиента;
- Никогда не сочетать подстановочный знак с разрешением учётных данных;
- Отвечать на предпроверку успешным статусом и полным набором заголовков;
- Кэшировать предпроверку осознанным значением Max Age вместо дефолтных пяти секунд;
- Проверять права на сервере независимо от CORS, потому что браузерная проверка не защищает от небраузерных клиентов.
Приватные сети и новые требования браузера к запросам внутрь
Отдельная глава разворачивается вокруг приватных сетей. Запрос с публичного сайта в локальную сеть - приглашение для атак подделкой запроса против домашних роутеров и офисной техники. Стандарт Private Network Access, выросший из черновика CORS-RFC1918, требует от браузера спрашивать у целевого сервера явное согласие: предпроверка OPTIONS получает заголовок Access-Control-Request-Private-Network: true, а сервер обязан ответить Access-Control-Allow-Private-Network: true, иначе запрос внутрь сети не уйдёт.
Адресные пространства стандарта делятся на публичное, приватное и локальное: публичная страница, приватная сеть вроде домашней или офисной и localhost. Правило простое: чем цель приватнее инициатора, тем строже согласие. Касается это и локальной разработки: вызов localhost API с публично открытой страницы считается запросом в приватную сеть.
Chrome двигал механизм поэтапно: с 94 версии блокируются запросы в приватные сети из небезопасных публичных страниц, а в сентябре 2023 года версия 117 закрыла период отсрочки, запретив такие запросы окончательно. График дальнейших ограничений для безопасных страниц переносился, поэтому перед внедрением новых требований разумно сверяться с релизными заметками браузера.
Красная ошибка в консоли - не каприз браузера, а напоминание о границе между своим и чужим. Правильно настроенный CORS нарочно скучен: белый список, точное совпадение, кэш предпроверки и никакого отражения чужих адресов. Драма живёт в исключениях: учётные данные, значение null и запросы в приватные сети, где поспешные решения открывают чужой странице лишнее. Скучная конфигурация в обмен на спокойную границу - честная сделка.