Files
lms-sb/docs/plans/20260912-clean-pdf-состояние.md
adminsandClaude Opus 5 5ad0bd98f1 Document clean-pdf state, correct SSRF limits and prod gate
Audit of the shipped Chistyy PDF feature against the actual code found
three documentation defects and one misplaced gate:

- The SSRF write-up understated the hole. assertPublicUrl resolves DNS
  exactly once, for the initial URL; the in-browser filter never resolves
  hostnames at all. Any new hostname after the first navigation (redirect,
  subresource, fetch, ws://) goes unchecked - a DNS rebind is not even
  required. Corrected in TECHNICAL.md and the design spec.
- The prod gate was tied to TOOLBOX_VISIBLE, but /api/pdf sits in
  PUBLIC_ROUTES and authenticates itself, so the feature goes live the
  moment browserless and BROWSER_WS_URL appear on prod - before the flag.
  Gate is now tied to the renderer.
- TECHNICAL.md claimed the browserless port is published on neither
  staging nor prod. It is published on dev/staging (127.0.0.1:3333) and
  the SSH tunnel depends on it.
- AGENTS.md described a src/proxy.ts that does not exist; route protection
  lives in src/middleware.ts.

Also adds a state snapshot (docs/plans) and a "grabli uklada" section to
CLAUDE.md covering the non-obvious conventions already enforced in code:
the two ToolUsage ids, the vitest include pattern, page.pdf() without a
timeout option, context.route not seeing WebSockets, and NEXT_PUBLIC_*
being inlined at build time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sy7vY7WQ1A3q1MkgsDd8VB
2026-09-12 12:55:46 +05:00

7.6 KiB
Raw Permalink Blame History

Чистый PDF — снимок состояния

Дата снимка: 20260912 Фаза: слито в main, работает на staging, на прод не выкачено Охват: только фича «Чистый PDF». Состояние остальных частей LMS здесь не отражено.


Коротко

Инструмент URL → чистый PDF в Obsidian Toolbox: веб-форма в кабинете, персональный API-ключ + скрипт для Zotero (Actions & Tags), открытый curl API. Рендер — отдельный контейнер browserless (Chromium по CDP), контент выделяет Defuddle, печатает page.pdf().

Сделано 20260706 по плану из 11 задач (субагентное исполнение с ревью после каждой). Merge 53caf54, 21 коммит. Финальное whole-branch ревью — READY TO MERGE.

Состояние сред (проверено 20260912)

Среда Состояние
main Фича на месте и цела: файлы побайтово совпадают с merge 53caf54; /api/pdf в PUBLIC_ROUTES, регистрация инструмента и модель PdfApiKey не пострадали от августовских коммитов
Прод (Hoster.kz) Выключено. Контейнера browserless нет, BROWSER_WS_URL/BROWSERLESS_TOKEN/TOOLBOX_VISIBLE в /root/lms-sb/.env не заданы. Репозиторный docker-compose.prod.yml намеренно расходится с живым /root/lms-sb/docker-compose.yml
Staging (Hetzner) Работает. Стенд стоит ровно на 53caf54 и отстал от main на августовские коммиты. TOOLBOX_VISIBLE=true, контейнеры lms-staging-app-1 и lms-staging-browserless-1 живы с 20260706

Проверено E2E на staging 20260706: 401 без ключа, 403 без платного доступа, 200 с реальным PDF (576 КБ, 4 страницы), 422 на SSRF-пробы (192.168.x, localhost:5432, [::ffff:169.254.169.254]). Страница и перевыпуск ключа проверены в живом браузере.

Что где лежит

  • Код: src/lib/clean-pdf/ (ssrf.ts, template.ts, generate.ts, access.ts, keys.ts, api-key.ts, zotero-script.ts), роут src/app/api/pdf/route.ts, страница src/app/(student)/tools/clean-pdf/, действие src/lib/actions/pdf-key-actions.ts
  • Данные: модель PdfApiKey, миграция 20260706120000_add_pdf_api_key; учёт — существующая ToolUsage
  • Документы: спека docs/specs/20260706-clean-pdf-design.md, план docs/superpowers/plans/20260706-clean-pdf.md, раздел в TECHNICAL.md
  • Уклад и грабли: раздел «Грабли уклада» в CLAUDE.md
  • В базе знаний: SBT/00-Стек/Сервисы/clean-pdf.md, SBT/03-Опыт/Постмортемы/20260706-clean-pdf-ssrf-обходы.md, гейт в SBT/02-Стандарты/Процессы/lms-deploy.md

⚠️ Гейт перед прод-релизом

Закрыть исходящий трафик контейнера browserless — заблокировать 169.254.0.0/16 и RFC1918 через internal-сеть compose или DOCKER-USER. На 20260912 не сделано ни в репозитории (у сервиса нет networks/internal), ни на хосте (DOCKER-USER пуста).

Гейт привязан к рендереру, а не к флагу. TOOLBOX_VISIBLE прячет только страницы /tools/*; /api/pdf лежит в PUBLIC_ROUTES и авторизуется сам, поэтому фича оживёт для любого платного студента в момент появления browserless и BROWSER_WS_URL — ещё до поднятия флага.

Причина: assertPublicUrl резолвит DNS один раз, для исходного URL, а фильтр внутри браузера хостнеймы не резолвит вовсе. Непроверенным уходит любой новый хостнейм после первого перехода (редирект, субресурс, fetch, ws://) — DNS-rebind для обхода не нужен.

Хвосты (по убыванию важности)

  1. Egress-изоляция browserless — гейт выше. Блокирует прод-релиз.
  2. Фикс WebSocket-обхода (routeWebSocket) не покрыт тестом. Баг с v4-mapped IPv6 закрыт юнит-тестами ssrf.test.ts, а браузерный фильтр не тестируется вовсе (единственный тест генерации за RUN_PDF_INTEGRATION). Регрессия пройдёт молча.
  3. Образ browserless не запинен — ghcr.io/browserless/chromium без тега в обоих compose. Контейнер исполняет контент атакующего; обновление меняет Chromium и поведение CDP-слоя, на котором держатся фильтры.
  4. NEXT_PUBLIC_APP_URL не доезжает до образа — задан только как runtime-env в compose, а NEXT_PUBLIC_* инлайнятся на сборке. Работает захардкоженный фолбэк на прод-адрес: на staging студенту выдаются Zotero-скрипт и curl с прод-URL. Лечится --build-arg, как у NEXT_PUBLIC_TURNSTILE_SITE_KEY.
  5. Ключи PdfApiKey лежат в БД открытым текстом (поиск прямым findUnique). Дампы, реплика на standby и PITR содержат рабочие ключи. Ущерб ограничен (только генерация в пределах лимита, доступ проверяется на каждый запрос), но при релизе стоит перейти на хеш.
  6. Лимит не расходуется неуспешными генерациями — ToolUsage пишется строго после успешного рендера, поэтому ни месячная квота, ни burst не тратятся на отказы. Ограниченный self-DoS тяжёлыми URL. Рассмотреть подсчёт попыток.
  7. RenderError обёрнута только часть вызовов браузера (connectOverCDP, goto, таймаут печати). newContext, newPage, content, route/routeWebSocket и reject самого pdf() дают сырую ошибку Playwright — её ловит catch-all роута и отдаёт 504.

Маркеров TODO/FIXME в коде фичи нет — хвосты живут в документах.

Если возвращаемся к работе

  1. Прочитать этот файл и «Грабли уклада» в CLAUDE.md.
  2. Перед любым касанием прода — SBT/02-Стандарты/Процессы/lms-deploy.md (там гейт).
  3. Staging отстал от main — перед проверками подтянуть ветку и передеплоить (bash ~/Documents/Claude/scripts/deploy-staging.sh; скрипт иногда не пересоздаёт контейнер, тогда вручную docker compose up -d app).