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