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') она роняет загрузку в трёх ситуациях:
- переменная отсутствует или состоит только из пробелов;
- переменная равна литералу примера из репозитория;
- переменная короче 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.