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

ADR 0058 — Закрытый CSP в api и область проекта, удержанная в корне

  • Статус: принят
  • Дата: 2026-08-08
  • Контекст: открытые предупреждения CodeQL (сканирование от 2026-08-04)
  • Пересматривает: ADR 0027, пункт 7
  • Затрагивает: ADR 0055

Контекст​

Сканирование кода CodeQL оставило десять открытых предупреждений HIGH. Семь из них — ложные срабатывания, и они отклонены с письменной причиной прямо в GitHub (список находится в конце этого документа, потому что причина отклонения — тоже решение). Остальные три реальны и меняют дизайн, и это то, что фиксирует этот ADR.

1. contentSecurityPolicy: false (js/insecure-helmet-configuration)​

ADR 0027, пункт 7, решил «helmet в api, CSP только в web», с contentSecurityPolicy: false. Аргумент был таков: api отдаёт JSON, скрипты выполняет web, а CSP web уже существует и более специфичен (docker/web/nginx.conf, с connect-src, собираемым для каждого окружения). Включение общего CSP в api создало бы впечатление покрытия, не добавив защиты.

Этот аргумент остаётся верным в том, что он утверждает, и именно поэтому он пережил целую фазу. Чего он не учёл: альтернатива общему CSP — не отсутствие заголовка, а специфичный CSP. А для api, которая отдаёт только JSON, специфичный — самый закрытый из существующих: default-src 'none'. Она не загружает скрипты, таблицы стилей, изображения, шрифты или фреймы, поэтому запретить всё стоит ноль поведения.

И есть два конкретных пути, на которых ответ api становится поверхностью выполнения, где CSP web отсутствует, потому что web не на пути:

  • прямая навигация на маршрут api — вставленная ссылка, redirect, вкладка, открытая пользователем. Браузер отображает ответ на ORIGIN API, где CSP nginx web не действует;
  • frame-ancestors, который действует только на обрамляемый документ. Ни один CSP web не мешает третьей стороне обрамить маршрут api.

2. join(raiz, projectId) без проверки projectId (js/path-injection)​

projectId приходит в @Param('projectId') без pipe проверки, и Express декодирует percent-encoding сегмента до того, как передать его: ..%2F..%2Fetc приходит как ../../etc, и join разрешается за пределы корня без жалоб.

Охват больше, чем «читает не тот файл». У projectScopeRoot два потребителя, и второй — тот, что болит:

  • permissions.json читался бы и записывался по произвольному пути (fs-permissions-file-store.ts);
  • область пути из ADR 0055 разрешает команды терминала под этой папкой (propose-action.use-case.ts → decide.ts). Область, которая выходит из корня, — это политика одобрения, указывающая не туда, — сбой БЕЗОПАСНОСТИ, а не «файл не найден».

3. Неполное экранирование ячейки таблицы (js/incomplete-sanitization)​

В теле PR продвижения (scripts/ci/promote.ts) заголовок PR экранировался с помощью .replace(/\|/g, '\\|'). Экранирование только вертикальной черты пропускает заголовок, оканчивающийся обратной косой чертой: a\, за которым следует |, становится a\\|, что парсер таблиц GFM читает как экранированную обратную косую черту, за которой следует РАЗДЕЛИТЕЛЬ столбца.

Решение​

1. api отправляет CSP, и он закрыт. Опции helmet переезжают из литерала в main.ts в infrastructure/security/security-headers.ts — то же движение, которое уже сделал cors-origins.ts, и по той же причине: в литерале boot они не были тестируемыми, и ни один тест не видел, какой заголовок api на самом деле отправляет.

В production:

default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'

Вне production main.ts монтирует Swagger UI в /docs, который является настоящим HTML и нуждается в собственных скриптах, стилях и изображениях, — под default-src 'none' страница открылась бы пустой. Профиль разработки ослабляет необходимое для Swagger и ничего сверх того. Условие — РОВНО то же, что монтирует Swagger (NODE_ENV !== 'production'), и это намеренно: если однажды Swagger начнёт подниматься в production, CSP последует за ним, а не будет молча его блокировать.

'unsafe-inline' появляется только в профиле разработки, и это ограничение Swagger UI (он внедряет инициализатор inline), а не наш выбор.

crossOriginResourcePolicy перестаёт быть false и становится { policy: 'cross-origin' }. Эффект в браузере тот же — web — другой origin и должен потреблять эти ответы, — но намерение становится СКАЗАННЫМ в заголовке, а не опущенным в его отсутствие.

2. projectId проверяется там, где выводится корень, а не в каждом вызывающем. projectScopeRoot отклоняет то, что не является простым сегментом пути (^[A-Za-z0-9_-]{1,64}$), бросая исключение. Проверка намеренно шире, чем UUID, чтобы не привязывать формат id, и достаточно узка, чтобы результат никогда не выходил из корня.

Проверка в одном месте — по той же причине, по которой эта функция существует: два вывода должны совпадать, а дублированная проверка — это проверка, которая однажды разойдётся.

3. Экранирование ячейки экранирует обратную косую черту перед вертикальной чертой, в celulaDeTabela. Действует для ТЕКСТОВОЙ ячейки; в ячейке, которая является code span, обратная косая черта буквальна, и её экранирование отобразило бы видимое \\ — поэтому функция не используется в столбцах между обратными кавычками.

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

  • api начинает отправлять Content-Security-Policy в каждом ответе. Тот, кто полагался бы на открытие маршрута api прямо в браузере и видел бы что-то кроме сырого JSON, больше не полагается — и такого случая не было.
  • Некорректный projectId теперь ПРОВАЛИВАЕТСЯ, вместо того чтобы разрешиться за пределы корня. Счастливый путь не меняется: каждый реальный id — UUID из базы.
  • Заголовки перестали быть невидимыми для теста. Доказательство — настоящий запрос к middleware, а не утверждение об объекте конфигурации — false и объект директив — обе «допустимые конфигурации», и разница между ними проявляется только в HTTP-ответе.

Что было отклонено и почему​

Отклонение с письменной причиной — это ответ; молча оставить открытым — нет. Семь:

предупреждениеправилопричина
#5js/insufficient-password-hashПароля нет. secret — это GIT_OAUTH_STATE_SECRET, серверный HMAC-ключ, а HMAC-SHA256 — ПРАВИЛЬНЫЙ примитив для подписи state OAuth. Медленный хеш там был бы ошибкой. Пароль пользователя в продукте использует argon2id, в argon2-password-hasher.ts.
#4js/loop-bound-injectionЦикл завершается. b — результат String.prototype.split, настоящий массив, .length не подделываем, а j увеличивается на каждом витке. Предпосылка правила (контролируемый объект с поддельным .length) здесь не выполняется.
#7, #8js/incomplete-sanitizationВ scripts/docs/generate.mjs обе ячейки — code span (между обратными кавычками), где обратная косая черта буквальна: её экранирование отобразило бы видимое \\: «исправление» сломало бы вывод. Входные данные — содержимое самого репозитория (скрипты package.json, метки разделов документации), в генераторе сборки.
#9js/incomplete-multi-character-sanitizationЭто ТЕСТОВЫЙ файл. replace убирает комментарии из index.html, версионируемого В ЭТОМ репозитории, чтобы утверждать, что он не ссылается на CDN шрифтов. Это не граница санитизации недоверенных входных данных.
#1, #2, #3js/path-injectionЗакрыты решением 2 выше; отклонение не применяется — они входят сюда лишь для того, чтобы список из десяти был полным.

Остались открытыми, в Dependabot: image-size (два предупреждения HIGH). Опубликованной исправленной версии нет — 2.0.2 последняя в реестре, и она уязвима. Входит через @docusaurus/mdx-loader, сборку документации, читающую изображения, версионируемые в этом репозитории; недоверенных входных данных на пути нет.