Скрипт витрины вызывает 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;
    }
}

Чистая настройка укладывается в контрольный перечень:

  1. Сверять origin строгим равенством с белым списком, а не подстрокой или суффиксом;
  2. Возвращать Vary Origin вместе с точным значением адреса клиента;
  3. Никогда не сочетать подстановочный знак с разрешением учётных данных;
  4. Отвечать на предпроверку успешным статусом и полным набором заголовков;
  5. Кэшировать предпроверку осознанным значением Max Age вместо дефолтных пяти секунд;
  6. Проверять права на сервере независимо от 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 и запросы в приватные сети, где поспешные решения открывают чужой странице лишнее. Скучная конфигурация в обмен на спокойную границу - честная сделка.