Перейти к основному содержимому

ADR 0037 — CORS, которого не было у engine, и порт как часть контракта

Контекст​

Сообщение о «проблеме CORS между web и api» привело к проверке трёх пар взаимодействия системы. Результат: одна сломанная пара, одна пара, которая никогда не была под угрозой, и первопричина, которая была вовсе не в CORS.

Всё ниже измерено, с curl -H "Origin: …" и с headless Chrome, читающим консоль, — а не выведено из чтения кода.

Что правильно​

парамеханизмвердикт
web → api (HTTP)CORS Nest, WEB_ORIGINok — preflight возвращает allow-origin, allow-headers с четырьмя заголовками, allow-credentials
web → engine (WebSocket)check_origin Phoenixok — handshake отвечает 101; WebSocket не проходит через CORS, а check_origin уже читал WEB_ORIGIN начиная с Фазы 4a
api → engine (/internal/*)service token (RN-035)ok, и CORS неприменим — 401 без токена, 400 валидации с токеном, и ИДЕНТИЧНЫЙ ответ с Origin и без
engine → api (/internal/*)service tokenok, и CORS неприменим — 403 без токена, 400 с токеном, то же для Origin

Последние две стоит проговорить полностью, потому что вопрос естественен, а ответ структурен: CORS — механизм браузера. Вызывающий в этих двух направлениях — серверный HTTP-клиент (fetch Node в api, Finch в engine), который не реализует same-origin policy и полностью игнорирует эти заголовки. Настраивать нечего, а настройка была бы хуже — см. решение 2.

Что сломано: web → engine по HTTP​

У endpoint engine не было никакого CORS. GET /health отвечал 200 с правильным телом и без единого заголовка Access-Control-*, поэтому браузер отбрасывал ответ:

Access to fetch at 'http://localhost:4000/health' from origin
'http://localhost:5173' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.

Видимый эффект: StatusPage показывала engine: error при совершенно здоровом engine. Ни один тест этого не ловил, и не по небрежности — на стороне сервера ответ был правильным. Не хватало заголовка, а тест контроллера не утверждает заголовок CORS.

Дефект старше этой сессии, но стал заметнее теперь: ADR 0036 сделал /status публичной и связал её с футером экранов auth, так что неверная строка стала достижимой до входа.

Первопричина исходного сообщения, которая не была CORS​

vite.config.ts фиксировал port: 5173 без strictPort. Когда 5173 занят — забытый pnpm dev в другом терминале, compose dev на том же хосте, — Vite поднимается на 5174 и предупреждает одной строкой лога загрузки. Api принимает ровно http://localhost:5173, поэтому приложение открывается нормально, а всё блокируется:

blocked by CORS policy: … 'http://localhost:3000/health' (origin 5174)
blocked by CORS policy: … 'http://localhost:3000/auth/refresh' (origin 5174)
blocked by CORS policy: … 'http://localhost:4000/health' (origin 5174)

Заблокированный /auth/refresh — то, из-за чего экран выглядит разлогиненным. А сообщение говорит о CORS, а не о порте — поэтому всё время уходит не туда. Хуже: естественное «исправление» — ослабить CORS api, что чинит 5174 и ломает 5173.

Решение​

1. Собственный plug CORS в engine вместо Corsica​

EngineWeb.Plugs.Cors, ~40 строк логики. Corsica — очевидный выбор и решает гораздо больше, чем нужно: этот plug обслуживает GET/HEAD, без учётных данных, на трёх фиксированных путях, с двумя заголовками в списке. CLAUDE.md требует обоснования для новой библиотеки, и «40 строк» — обоснование.

Если однажды engine выставит настоящий API для браузера — POST, cookie, собственный заголовок, — замена на Corsica начнёт окупаться. Moduledoc это фиксирует, чтобы следующий человек знал, что альтернатива рассматривалась.

2. Plug стоит в ENDPOINT и фильтрует по пути​

Не в pipeline роутера, и причина измерима: pipeline роутера выполняется только после совпадения маршрута. Маршрута OPTIONS нет, поэтому preflight умирает с 404 до любого plug pipeline — проверено до исправления (OPTIONS /health → 404). Plug CORS, не видящий preflight, — это пол-plug, а недостающая половина — та, что ломается в день, когда web добавит заголовок.

В endpoint он видит всё, и цена — явно сказать, где он применяется. Это тот же дизайн, что у EngineWeb.Plugs.AccessLog, который тоже фильтрует по префиксу пути, потому что не может зависеть от роутера.

Allowlist — /health, /live и /ready. Два намеренных исключения, оба — из соображений безопасности:

  • /internal/* — 13 маршрутов, через которые api командует engine. Как установлено в контексте, CORS там ничего не включил бы; что он сделал бы — объявил бы браузеру, что тот — ожидаемый клиент этого канала. Это информация, которую мы не хотим давать, о поверхности, до которой мы не хотим, чтобы кто-то пытался достучаться из браузера.
  • /metrics — scrape Prometheus. Внутренней метрике незачем быть читаемой JavaScript какой-либо страницы.

3. Неизвестный origin получает ответ, а не 403​

Запрос обслуживается нормально и уходит без заголовка; чтение блокирует браузер, которому это решение и принадлежит. Ответ 403 превратил бы каждый законный запрос без Origin — probe kubelet, curl, docker/smoke.sh — в новый режим отказа, созданный случайно при починке другого.

vary: origin сопровождает allow-origin. Это не украшение: без него прокси, сохранивший ответ для одного origin, может отдать его другому с неправильным заголовком внутри.

4. WEB_ORIGIN читается ОДИН раз и питает обоих потребителей​

check_origin сокета уже читал переменную; новому plug нужен был тот же список. Дублированное чтение — то, как появилась дыра: у сокета origin был настроен две фазы, а у HTTP не было ничего, потому что ничто не обязывало их идти вместе.

Теперь runtime.exs вычисляет :web_origins один раз, наверху, а check_origin начинает выводиться из него.

В продакшене нет значения по умолчанию для разработки. Api бросает исключение при загрузке, когда WEB_ORIGIN отсутствует (cors-origins.ts); engine оставляет список пустым, что закрывает доступ из браузера, не роняя процесс. Асимметрия намеренная: CORS — смысл существования того участка api, а в engine он периферийный — очереди Oban и каналы Phoenix продолжают работать. Engine, который не поднимается из-за панели статуса, меняет маленькую проблему на большую.

Пустой список также не становится check_origin: [], который Phoenix прочитал бы как «ни один origin не подходит» и уронил бы живую панель команды. В этом случае действует строгое значение Phoenix по умолчанию (true, сравнение с PHX_HOST).

5. strictPort: true в Vite​

Порт — часть контракта CORS, поэтому его нельзя выбирать молча. С strictPort Vite отказывается подниматься и говорит Port 5173 is already in use — это правдивая информация, вместо трёх ошибок CORS, указывающих не туда.

Цена реальна и принята: тому, кто хочет два dev-сервера одновременно, теперь нужен явный --port. Явный выбор — ровно то, что нужно, потому что другой порт всё равно должен войти в WEB_ORIGIN.

6. maxAge в CORS api и engine​

Каждый вызов web к api проходит preflight: api-client шлёт Authorization и traceparent, которые не являются safelisted. Без кэша preflight каждый запрос — две поездки. Кэш браузера — по URL+методу, и при refetchInterval TanStack Query, бьющем в один и тот же URL снова и снова, именно там он окупается.

10 минут в обоих. Достаточно коротко, чтобы изменение allowedHeaders не застревало в кэше того, у кого была открыта вкладка.

Последствия​

  • StatusPage начинает говорить правду. Раньше: api: ok, engine: error при работающем engine. Теперь: оба ok и ноль ошибок CORS в консоли.
  • Коллизия порта перестаёт маскироваться под проблему CORS. Это обмен молчаливого и обманчивого режима отказа на шумный и правильный.
  • /internal/* остаётся без CORS, и теперь есть тест, это утверждающий. Граница стала утверждением — включая утверждение, что в списке путей ровно три записи, чтобы перемещение границы появлялось в diff.
  • На одну зависимость меньше, чем у очевидного решения, и moduledoc, объясняющий, когда пересмотреть решение.
  • Engine получил свой первый тест HTTP-заголовка. 15 случаев покрывают то, что тест контроллера структурно не покрывает: ответ был правильным, а заголовка не хватало.

Что остаётся хвостом​

  • exposedHeaders не настроен ни в одном из двух. Сегодня web не читает ни одного заголовка ответа — trace_id у ApiError берётся из trace, который сгенерировал сам клиент, а не сервер. Настраивать сейчас значило бы включить неиспользуемую возможность.
  • У engine нет CORS для POST, намеренно: ничто в браузере не делает к нему POST. Когда начнёт, список методов и список заголовков должны вырасти вместе — тест preflight — место, где это будет замечено.
  • EngineWeb.RouteSurfaceTest красный в origin/dev ещё до этого изменения (ActionClauseError в SessionCommandController.create/2, из-за пустого тела в тесте, утверждающем, что внутренние маршруты принимают валидный токен). Проверено, что он падает так же без каких-либо изменений этой поставки, и ни один коммит отсюда не трогает apps/engine, кроме описанного выше. Зафиксировано, не исправлено: это другой вопрос.