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

0044. Минимизация раскрытия в production и fail-fast на небоевых секретах

Копия ADR из репозитория: backend/docs/adr/0044-production-disclosure-and-secret-fail-fast.md. Канонический текст — в репозитории; портал обновляется командой npm run sync:adr.

  • Статус: accepted
  • Дата: 2026-08-05
  • Связанные задачи: аудит безопасности 2026-08-05 (находка 2, hardening H3/H4/H5/H7/H8/H9)

Контекст и проблема​

Аудит 2026-08-05 показал, что периметр сервиса раздаёт наружу больше, чем нужно для работы, и не защищён от очевидной ошибки эксплуатации:

  1. Портал документации раздавал исходный код. .compodocrc.json не задавал disableSourceCode, поэтому Compodoc клал в каждую страницу вкладку «Source» с полным текстом .ts-файла. Артефакт публикуется на docs.optimawb.ru через nginx без авторизации: 43 из 43 injectable-файлов, включая реализацию шифрования WB-токенов (EncryptionService), были доступны анонимно (CWE-540).
  2. Анонимный /health отдавал текст ошибки подключения к PostgreSQL/Redis (адреса, порты, коды драйвера) и uptime процесса.
  3. Заглушечные секреты работоспособны. Ключ AES и JWT-секреты из .env.example и docker-compose.yml — валидные значения: деплой, забывший выставить переменные в Coolify, поднимется на них молча, и любой читатель репозитория сможет подписать себе токен и расшифровать WB-ключи из дампа БД.
  4. X-Request-Id от клиента принимался как есть и попадал в каждую строку структурного лога и в заголовок ответа: инъекция перевода строки в JSON-лог, раздувание записей, 500 из-за недопустимых символов в res.setHeader.
  5. Маскирование секретов работало только по имени ключа — строковые message/stack не сканировались, и токен из текста ошибки HTTP-клиента попадал в лог целиком.
  6. Контейнер бэкенда работал от root; локальный docker-compose публиковал PostgreSQL и Redis (без пароля) на все интерфейсы.

Общий знаменатель: сервис по умолчанию делится информацией, хотя ни один из перечисленных фрагментов не нужен внешнему потребителю.

Рассмотренные варианты​

  1. Точечные заплатки без общего правила — закрыть каждый пункт отдельно. Плюсы: минимум изменений. Минусы: следующая фича повторит ту же ошибку, потому что «по умолчанию» осталось прежним.
  2. Закрывать периметр инфраструктурой (basic-auth на портал документации, фильтрация /health на прокси). Плюсы: код не меняется. Минусы: защита живёт вне репозитория, не проверяется тестами и теряется при переезде; Coolify-конфигурация не версионируется.
  3. Правило «в production наружу — только необходимое», зашитое в код, плюс fail-fast на небоевых секретах. Плюсы: поведение проверяется юнит-тестами и переезжает вместе с кодом; ошибка эксплуатации становится громкой. Минусы: расхождение поведения между окружениями; неверно настроенный прод не поднимется вовсе.

Решение​

Выбран вариант 3.

Раскрытие. В production ответ GET /health сокращается до { status, timestamp }; детализация по компонентам и текст ошибки уходят только в лог (HealthService.runCheck). uptime убран из /health и /health/live во всех окружениях. Поля uptime и checks в HealthCheckResultDto объявлены опциональными, а не удалены — контракт OpenAPI остаётся обратно совместимым. Вне production ответ остаётся подробным: там он нужен разработчику.

Compodoc собирается с disableSourceCode, disablePrivate, disableInternal: портал документирует контракт кода (сигнатуры, TSDoc, зависимости), а не его реализацию. Порог docs:coverage (80%) при этом сохраняется — фактическое покрытие 86%.

Секреты. EncryptionService.onModuleInit в production падает (InsecureSecretError), если активный ключ шифрования или любой из JWT-секретов совпадает с заглушкой из репозитория, содержит маркер заглушки (change-me, placeholder, …) или низкоэнтропиен (повтор короткого блока, бедный алфавит); отдельно отвергается совпадение access- и refresh-секретов. Проверка сознательно ограничена активным ключом: неактивные ключи ring — это ровно те, с которых прод уходит, и запрет на них заблокировал бы ротацию.

Логи. Входящий X-Request-Id принимается только по /^[A-Za-z0-9._-]{1,128}$/, иначе генерируется randomUUID() (запрос не отклоняется — трассировка не повод возвращать 400). К маскированию по имени ключа добавлен второй эшелон по значению: строки (message, stack, любые строковые поля meta) сканируются на формат JWT (eyJ…) и Bearer <token>.

Периметр контейнеров. Образ бэкенда запускается от непривилегированного node (COPY --chown + USER node); docker-compose.yml публикует порты только на 127.0.0.1 и включает --requirepass для Redis.

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

  • Положительные:
    • публичный портал больше не является бесплатной разведкой по коду;
    • анонимный /health не раскрывает топологию и версии драйверов;
    • деплой с забытыми переменными окружения падает громко и сразу, а не работает с ключом, известным всем;
    • лог перестаёт быть каналом утечки токенов и вектором инъекции.
  • Отрицательные / компромиссы:
    • поведение /health различается между окружениями — при разборе инцидента в проде нужно смотреть лог, а не ответ пробы;
    • fail-fast может уронить прод при переходе на новый ключ, если значение выбрано неудачно (например, openssl rand заменили «красивой» строкой); сообщение об ошибке называет переменную и способ сгенерировать значение;
    • value-based маскирование теоретически может замаскировать безобидную строку, начинающуюся с eyJ (это всегда base64 от {", риск принят);
    • Redis в локальном compose теперь требует пароль: .env разработчика должен содержать REDIS_URL=redis://:optima-local@localhost:6379.
  • Что нужно сделать дальше:
    • пересобрать и передеплоить docs-site — старый образ продолжает раздавать исходники, пока не заменён;
    • привести backend/.env.example в соответствие с новым REDIS_URL (значение с паролем) — файл вне зоны этого изменения;
    • ротировать JWT- и AES-ключи в prod/stage Coolify, если они когда-либо совпадали со значениями из репозитория.