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

ADR 0059 — У ключа, подписывающего state git OAuth, нет значения по умолчанию

  • Статус: принят
  • Дата: 2026-08-08
  • Предыдущий контекст: ADR 0024 (секреты продакшен-compose), ADR 0058 (дефект проявился именно при снятии предупреждения об этом ключе)

Контекст​

При закрытии предупреждений безопасности ADR 0058 одно из них (js/insufficient-password-hash, об oauth-state.ts) было снято с аргументом, что пароля там нет: "password" — это GIT_OAUTH_STATE_SECRET, серверный HMAC-ключ, а HMAC-SHA256 — правильный примитив для подписи state OAuth.

Аргумент верен и остаётся в силе. Он не учёл того, что серверный ключ с известным значением по умолчанию ничего не подписывает — а именно таков был случай. Обе точки чтения делали:

process.env.GIT_OAUTH_STATE_SECRET ?? 'dev-oauth-state-secret-change-me'

Это тот же режим сбоя, который WEB_ORIGIN уже закрыл в cors-origins.ts: значение по умолчанию для разработки, действующее так же в продакшене. Здесь он хуже по двум причинам.

Первая — значение публично. Оно есть в .env.example репозитория с открытым исходным кодом. Это не слабое значение по умолчанию, которое атакующему нужно было бы угадать; это опубликованный секрет.

Вторая — то, что защищает ключ. state — это то, что не даёт подделать callback OAuth (см. docs/security-surface.md). С известным ключом любой подписывает валидный state для {projectId, userId, provider} по выбору, и callback записывает — в проект, указанный этим payload, — токен git, полученный от provider. Это CSRF в потоке подключения, с учётными данными git в качестве приза.

И путь к этому не был гипотетическим. docker-compose.prod.yml подставлял литерал как fallback:

GIT_OAUTH_STATE_SECRET: ${GIT_OAUTH_STATE_SECRET:-dev-oauth-state-secret-change-me}

Тот, кто забывал переменную, поднимал продакшен, подписывая ключом из примера, без какого-либо сигнала. Эта деталь решает форму исправления, и это самое важное в этом ADR: требование лишь того, чтобы переменная "не была пустой", ничего бы не поймало, потому что на реальном пути ошибки она была определена — с неверным значением.

Решение​

Разрешение ключа уходит из use cases и становится единой функцией в apps/api/src/infrastructure/security/oauth-state-secret.ts, рядом с cors-origins.ts, который решил эквивалентную проблему. В продакшене (NODE_ENV === 'production') она роняет загрузку в трёх ситуациях:

  1. переменная отсутствует или состоит только из пробелов;
  2. переменная равна литералу примера из репозитория;
  3. переменная короче 16 символов.

Вне продакшена значение по умолчанию продолжает действовать, потому что docker compose up без .env должен работать.

Проверка выполняется при загрузке, в main.ts, а не при первом использовании. api, которая падала бы лишь при попытке подключить git, могла бы неделями стоять, принимая state, подписанный публичным ключом, — ошибка должна появиться при старте, где она шумна и обратима.

Почему функция, а не проверка в каждом вызывающем​

По той же причине, по которой появился projectScopeRoot() (RN-092): было две копии одного литерала, в разных файлах (start-git-oauth.use-case.ts и handle-git-oauth-callback.use-case.ts). Дублированная проверка — это проверка, которая однажды разойдётся, — а разойдясь здесь, callback отклонял бы каждый легитимный state. Есть тест, специально покрывающий то, что оба конца подписывают и проверяют одним ключом.

Почему минимальная длина​

Таблица docs/reference/configuration.md всегда говорила об этой переменной "fraco = CSRF no fluxo de conexão de git". Порог превращает фразу в проверку. Он намеренно низкий: отклоняет senha123 и не высказывается о ключе, сгенерированном любым серьёзным способом, — openssl rand -base64 32 даёт 44 символа.

Что изменилось вне кода​

  • docker-compose.prod.yml перестаёт подставлять литерал. Строка остаётся там, с пустым значением, потому что её опущение скрыло бы, что переменная существует.
  • docker/smoke.sh генерирует собственный ключ и отбрасывает его вместе со стеком. Фиксированное значение в скрипте было бы ещё одним публичным литералом — ровно тем, что проверка существует, чтобы предотвратить.
  • README начинает показывать генерацию ключа рядом с командой, поднимающей продакшен-compose.

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

Намеренная поломка: продакшен, который сегодня поднимается без GIT_OAUTH_STATE_SECRET (или с ключом из примера), перестаёт подниматься, с сообщением, говорящим, что сгенерировать. Это цель, а не побочный эффект: этот продакшен уже был уязвим, и раньше не было способа это узнать. Это касается docker compose -f docker/docker-compose.prod.yml, задокументированного в README.

В Kubernetes ничего не меняется: deploy/k8s/base/common/externalsecrets.yaml уже брал переменную из хранилища секретов.

Остаётся открытым, и это больше этого ADR: тот же шаблон касается AUTH_JWT_SECRET, BRABO_SERVICE_TOKEN, CREDENTIALS_MASTER_KEY и SECRET_KEY_BASE, у всех из которых есть значение по умолчанию для разработки в продакшен-compose. Ни один здесь не тронут — область этого изменения — ключ OAuth, а обработка остальных мимоходом скрыла бы, что это четыре независимых решения, каждое со своим радиусом поломки. Зафиксировано в docs/explanation/backlog.md.