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
7.6 KiB
Чистый 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 для обхода не нужен.
Хвосты (по убыванию важности)
- Egress-изоляция browserless — гейт выше. Блокирует прод-релиз.
- Фикс WebSocket-обхода (
routeWebSocket) не покрыт тестом. Баг с v4-mapped IPv6 закрыт юнит-тестамиssrf.test.ts, а браузерный фильтр не тестируется вовсе (единственный тест генерации заRUN_PDF_INTEGRATION). Регрессия пройдёт молча. - Образ browserless не запинен —
ghcr.io/browserless/chromiumбез тега в обоих compose. Контейнер исполняет контент атакующего; обновление меняет Chromium и поведение CDP-слоя, на котором держатся фильтры. NEXT_PUBLIC_APP_URLне доезжает до образа — задан только как runtime-env в compose, аNEXT_PUBLIC_*инлайнятся на сборке. Работает захардкоженный фолбэк на прод-адрес: на staging студенту выдаются Zotero-скрипт и curl с прод-URL. Лечится--build-arg, как уNEXT_PUBLIC_TURNSTILE_SITE_KEY.- Ключи
PdfApiKeyлежат в БД открытым текстом (поиск прямымfindUnique). Дампы, реплика на standby и PITR содержат рабочие ключи. Ущерб ограничен (только генерация в пределах лимита, доступ проверяется на каждый запрос), но при релизе стоит перейти на хеш. - Лимит не расходуется неуспешными генерациями —
ToolUsageпишется строго после успешного рендера, поэтому ни месячная квота, ни burst не тратятся на отказы. Ограниченный self-DoS тяжёлыми URL. Рассмотреть подсчёт попыток. RenderErrorобёрнута только часть вызовов браузера (connectOverCDP,goto, таймаут печати).newContext,newPage,content,route/routeWebSocketи reject самогоpdf()дают сырую ошибку Playwright — её ловит catch-all роута и отдаёт 504.
Маркеров TODO/FIXME в коде фичи нет — хвосты живут в документах.
Если возвращаемся к работе
- Прочитать этот файл и «Грабли уклада» в
CLAUDE.md. - Перед любым касанием прода —
SBT/02-Стандарты/Процессы/lms-deploy.md(там гейт). - Staging отстал от
main— перед проверками подтянуть ветку и передеплоить (bash ~/Documents/Claude/scripts/deploy-staging.sh; скрипт иногда не пересоздаёт контейнер, тогда вручнуюdocker compose up -d app).