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
This commit is contained in:
2026-09-12 12:55:46 +05:00
co-authored by Claude Opus 5
parent 03aaec76c2
commit 5ad0bd98f1
5 changed files with 141 additions and 15 deletions
+3 -5
View File
@@ -36,8 +36,7 @@ Production: **https://school.second-brain.ru**
Эти технологии отличаются от того, что содержится в обучающих данных большинства моделей. **Читай документацию перед написанием кода.**
### Next.js 16.2.2
- Используется `proxy.ts` вместо `middleware.ts`
- Экспортируемая функция называется `proxy`, не `middleware`
- Защита маршрутов — `src/proxy.ts` **НЕ существует**: весь код в `src/middleware.ts`, экспорт называется `middleware` (проверено 20260912). Next.js 16 поддерживает `proxy.ts`, но в этом проекте он не заведён — не создавать второй файл, править `middleware.ts`
- Перед написанием кода смотри `node_modules/next/dist/docs/`
### Tailwind CSS v4
@@ -119,8 +118,7 @@ lms-system/
│ │ ├── email.ts # Resend email helpers
│ │ └── utils.ts # cn() и утилиты
│ ├── types/ # TypeScript-типы
── proxy.ts # Auth middleware (защита маршрутов)
│ └── middleware.ts # Обёртка над proxy
── middleware.ts # Auth middleware (защита маршрутов) + PUBLIC_ROUTES
├── prisma/
│ ├── schema.prisma # Схема БД (~314 строк)
│ ├── seed.ts # Тестовые данные
@@ -142,7 +140,7 @@ lms-system/
| `curator` | `/curator/*`, `/dashboard` | Проверка ДЗ, комментарии |
| `student` | `/dashboard`, `/courses/*` | Просмотр курсов, прогресс |
Защита маршрутов — в `src/proxy.ts` + проверка сессии в layout/page.
Защита маршрутов — в `src/middleware.ts` (массив `PUBLIC_ROUTES`, сверка по `startsWith`) + проверка сессии в layout/page.
---
+67 -5
View File
@@ -18,7 +18,7 @@
| TipTap | 2.x | WYSIWYG-редактор уроков |
| @kinescope/react-kinescope-player | latest | Видеоплеер |
| Resend | latest | Email-уведомления |
| AWS SDK (S3) | 3.x | Hetzner Object Storage |
| AWS SDK (S3) | 3.x | Backblaze B2 (раздача через Bunny CDN) |
| Zod | 3.x | Валидация данных |
| Docker Compose | 2.x | Локальная разработка и деплой |
@@ -66,7 +66,7 @@ lms-system/
│ │ ├── auth.ts # Better Auth config (сервер)
│ │ ├── auth-client.ts # Better Auth client (браузер)
│ │ ├── prisma.ts # Prisma singleton client
│ │ ├── s3.ts # Hetzner Object Storage клиент
│ │ ├── s3.ts # Backblaze B2 (S3-совместимый) клиент
│ │ ├── email.ts # Resend email helpers
│ │ └── utils.ts # cn() и прочие утилиты
│ ├── types/
@@ -135,7 +135,7 @@ docker compose -f docker-compose.prod.yml logs -f app
- Перед `prisma migrate deploy` на production — делать бэкап БД
### Файлы и контент
- Загружаемые файлы (ДЗ, PDF) — только через Hetzner Object Storage, никогда на диск VPS
- Загружаемые файлы (ДЗ, PDF, картинки комментариев) — только в Backblaze B2, никогда на диск VPS
- Секреты (API-ключи, токены, строки подключения) — **только в `.env.local`**, в коде запрещено
- `.env.example` всегда обновлять при добавлении новых переменных (без реальных значений)
@@ -168,8 +168,9 @@ BETTER_AUTH_URL="http://localhost:3000"
RESEND_API_KEY=""
EMAIL_FROM="noreply@school.second-brain.ru"
# Hetzner Object Storage (S3-совместимый)
S3_ENDPOINT="https://fsn1.your-objectstorage.com"
# Backblaze B2 (S3-совместимый), раздача через Bunny CDN
S3_ENDPOINT="https://s3.eu-central-003.backblazeb2.com"
S3_CDN_URL="https://files.second-brain.ru"
S3_BUCKET="lms-uploads"
S3_ACCESS_KEY=""
S3_SECRET_KEY=""
@@ -181,6 +182,67 @@ S3_REGION="eu-central"
---
## Грабли уклада (проверено 20260912)
Неочевидные соглашения, уже действующие в коде. Сломать их легко «улучшением».
### Тесты
- `vitest.config.ts` ищет тесты **только** по `src/lib/**/__tests__/**/*.test.ts`. Тест рядом с исходником или в `src/app/**` молча не подбирается — зелёный `npm test` не значит, что твой тест выполнился. Проверяй, что файл попал в прогон.
- Интеграционный тест генерации PDF выключен по умолчанию: `describe.runIf(process.env.RUN_PDF_INTEGRATION === "1")` + нужен живой `BROWSER_WS_URL`. Гейт не снимать — тест ходит в интернет и в browserless.
- Решения выносим в чистую функцию с внедряемым IO, обёртка с IO остаётся тонкой (`decidePaidAccess` рядом с `hasPaidAccess`; `assertPublicUrl(raw, resolve)`). Prisma и dns в тестах **не мокаем**.
### Маршруты
- **`src/proxy.ts` не существует** — вся защита маршрутов в `src/middleware.ts`, экспорт `middleware`. Не заводить второй файл.
- Новый API-роут, который авторизуется сам (Bearer-ключ, внутренний секрет), обязан попасть в `PUBLIC_ROUTES` — иначе middleware отдаст редирект на `/login` раньше роута, и в браузере с кукой это не воспроизведётся. Обратная сторона: сверка идёт по `startsWith`, поэтому запись `/api/pdf` уже открывает любой будущий `/api/pdf-*` — такой роут обязан проверять доступ внутри себя.
### Чистый PDF (`/tools/clean-pdf`, `/api/pdf`)
- **Два разных значения `ToolUsage.tool`**: `clean-pdf-generate` (`PDF_TOOL_ID`) — только успешные генерации, по ним считаются месячный лимит и burst; `clean-pdf` — аналитика копирований из `CopyButton`. Не объединять: иначе каждое нажатие «Скопировать» списывает генерацию. `clean-pdf-generate` намеренно отсутствует в `TOOL_IDS`, чтобы публичный Server Action не мог списать квоту.
- Квота списывается **только после успешной** генерации, месячное окно — от начала месяца по UTC (`monthStartUtc`). Рефакторинг «сначала резервируем квоту, потом рендерим» начнёт списывать за каждую неоткрывшуюся страницу.
- `page.pdf()` в playwright-core не принимает `timeout` — оборачивать своим `withTimeout` (Promise.race), иначе зависшая печать держит слот `CONCURRENT` у browserless.
- `context.route("**/*")` **не видит WebSocket** — нужен отдельный `context.routeWebSocket`. Два похожих блока подряд — не дублирование, это вторая линия SSRF-обороны; удалять только осознанно.
- `isPrivateAddress(ip)`**fail-closed гейт, а не предикат**: на всё, что не разбирается как IP, возвращает `true`. Не переиспользовать как «адрес из приватного диапазона».
- В `resolveUserId` ветка Bearer терминальная: если заголовок `Authorization` есть, фолбэка на сессию нет ни при каком исходе. Не добавлять «если ключ не подошёл — попробуем куку»: запрос с чужим ключом из залогиненного браузера начнёт выполняться от имени владельца сессии.
- Заголовки ответа — публичный контракт с уже розданными ключами: `X-Uses-Count`, `X-Max-Uses` и двойной `Content-Disposition` (ASCII-фолбэк + RFC 5987 `filename*`). Их читают Zotero-скрипт и форма на сайте.
- Карта ошибок: `BlockedUrlError`/`EmptyContentError` → 422, `RenderError` → 504, остальное → 504, **никогда 500**. Текст `{error}` показывается студенту дословно — только по-русски, без внутренних деталей.
- HTML от Defuddle санируется собственным проходом в `template.prepareContent` (JSDOM: `script`/`style`/`iframe`/`object`/`embed`, все `on*`, `javascript:`) — намеренно **не** через `rehype-sanitize`, который заточен под Markdown-пайплайн комментариев.
- `zotero-script.ts` генерирует JS шаблонной строкой: учетверённые бэкслеши (`\\\\` в исходнике = один `\` в выданном скрипте). Ошибка экранирования не падает, а тихо ломает регулярки в выданном студенту скрипте — правил строку, прогони тест на валидность (`new Function(script)`).
### Env
- `NEXT_PUBLIC_*` инлайнятся **на сборке**. `NEXT_PUBLIC_APP_URL` задан только как runtime-env в `docker-compose.prod.yml`, в образ не попадает → работает захардкоженный фолбэк на прод-адрес. Следствие: на staging студенту выдаются Zotero-скрипт и curl-пример с **прод**-адресом. Правишь — передавай через `--build-arg`, как `NEXT_PUBLIC_TURNSTILE_SITE_KEY`.
### Тариф — это отдельный курс со своей копией уроков
`obsidian` и `obsidian-full` (как и `zotero` / `zotero-full`) — **две разные записи `Course`, у каждой свой полный комплект `Module` и `Lesson`**. Это не представления одного курса.
- **Правка контента применяется в обе копии.** Починил текст в «Всё включено» — в базовом тарифе он остался прежним. Копии уже расходились в проде: блок с благодарностью автору был только в дешёвом тарифе.
- **Комментарии сыплются под уроки обеих копий.** Ответ под уроком `zotero-full` студенты `zotero` не увидят; выборка «неотвеченные» должна идти по всем курсам сразу.
- Перед массовой правкой — `SELECT` по обоим slug, потом `UPDATE ... WHERE id IN (...)`.
### Markdown рендерится не везде
| Где | Что рендерится |
|---|---|
| Комментарии к урокам (`lesson-comments.tsx`) | Markdown + GFM: разметка, таблицы, голый адрес становится ссылкой |
| Отзыв куратора на ДЗ (`homework-section.tsx`) | простой текст (`whitespace-pre-wrap` + `linkify`) |
| Раздел вопросов (`QuestionThread.tsx`, `QuestionSplitView.tsx`) | простой текст (`whitespace-pre-wrap` + `linkify`) |
`**жирный**` в отзыве на ДЗ студент увидит звёздочками. Ссылку там же писать голым адресом можно — `linkify` её поднимет.
Безопасность рендера комментариев (не ослаблять «улучшением»): `rehype-sanitize` режет сырой HTML и `javascript:`; картинки с чужих доменов **не грузятся** — компонент `img` отдаёт их ссылкой, чтобы адрес читателя не утекал на сторонний сервер. Свои картинки — через `/api/student/comment-upload`.
### Файлы курсов живут на двух разных доменах
- `files.second-brain.ru` — Bunny CDN → Backblaze B2. Вложения уроков, ДЗ, картинки комментариев. Кеш короткий, правка «на месте» доезжает за час.
- `filez.second-brain.ru` — Selectel CDN → origin на Hoster.kz `/root/filez-static/`. Бонусы, архивы, шаблоны и CSS курсов. ⚠️ Кеш до 30 дней, **query-параметры игнорируются**: чтобы обновление дошло сразу, публиковать под новым путём или именем, а не `?v=2`.
Домены различаются одной буквой — легко перепутать при диагностике.
---
## Чек-лист перед каждым коммитом
- [ ] `npm run lint` — нет ошибок ESLint
+10 -3
View File
@@ -191,7 +191,7 @@ URL студента
→ application/pdf в ответе
```
Доступ: любой платный студент (есть `CourseEnrollment` вне `FREE_COURSE_SLUG`), admin/curator — без ограничений. Видимость страницы гейтится флагом `TOOLBOX_VISIBLE`, но сам `/api/pdf` работает независимо от него (доступ проверяется отдельно). Ключ для внешнего API — модель `PdfApiKey` (`sbpdf_<random>`, ленивая генерация, регенерация инвалидирует старый).
Доступ: любой платный студент есть `CourseEnrollment` на курс вне `FREE_COURSE_SLUG` и не истёкший (`expiresAt` пуст или в будущем); admin/curator проходят гейт без курсов. `banned` перекрывает всё, включая admin. «Без ограничений» — только про гейт доступа: квоты (burst 5/мин и `PDF_MONTHLY_LIMIT`) считаются по `userId` одинаково для всех ролей. Видимость страницы гейтится флагом `TOOLBOX_VISIBLE`, но сам `/api/pdf` работает независимо от него (доступ проверяется отдельно). Ключ для внешнего API — модель `PdfApiKey` (`sbpdf_<random>`, ленивая генерация, регенерация инвалидирует старый).
**Env-переменные:**
@@ -200,10 +200,17 @@ URL студента
| `BROWSER_WS_URL` | WebSocket-адрес browserless (`ws://browserless:3000` в compose, `ws://localhost:3333` при туннеле локально) |
| `BROWSERLESS_TOKEN` | Секрет browserless (`TOKEN` в его env) — общий и для сервиса, и для клиента в LMS |
| `PDF_MONTHLY_LIMIT` | Лимит генераций в месяц на студента (по умолчанию `100`), без пересборки |
| `TOOLBOX_VISIBLE` | Видимость раздела `/tools/*` и ссылок на него. На `/api/pdf` **не влияет** |
| `FREE_COURSE_SLUG` | Какой курс считается бесплатным (дефолт `obsidian-start`) — от него зависит, что считать платным enrollment |
| `NEXT_PUBLIC_APP_URL` | Базовый адрес в Zotero-скрипте и curl-примере. ⚠️ `NEXT_PUBLIC_*` инлайнится **на сборке**, а задан только как runtime-env в compose → в образе работает фолбэк на прод-адрес (на staging скрипт выдаётся с прод-URL) |
Контейнер `browserless` (`ghcr.io/browserless/chromium`) — внутренний, порт наружу не публикуется ни на staging, ни на проде.
Контейнер `browserless` (`ghcr.io/browserless/chromium`, образ **не запинен по версии**). На проде порт не публикуется вовсе — доступ только по внутренней сети compose. В dev и на staging порт публикуется на loopback хоста (`127.0.0.1:3333:3000`) — именно на этом держится SSH-туннель для интеграционного теста; наружу он при этом не торчит.
**SSRF-защита — честно про пределы:** до рендера URL проверяется резолвом DNS (блок приватных/зарезервированных диапазонов), плюс внутри browserless страница перехватывается фильтром буквальных приватных IP и `localhost`/`*.local`/`*.internal`. Этот browser-level фильтр **не** переразрешает хостнеймы — same-hostname DNS-rebind (публичный IP на первом резолве, приватный на повторном запросе изнутри browserless) он не закрывает. Полный разбор и требование захардить сетевой egress `browserless` перед включением `TOOLBOX_VISIBLE` на проде — см. «Безопасность» и «Деплой» в [`docs/specs/20260706-clean-pdf-design.md`](docs/specs/20260706-clean-pdf-design.md).
**SSRF-защита — честно про пределы:** исходный URL проверяется резолвом DNS (`assertPublicUrl`: блок приватных и зарезервированных диапазонов, включая v4-mapped IPv6), а внутри browserless запросы страницы фильтруются по `context.route` + `context.routeWebSocket`.
Предел у этой защиты шире, чем «DNS-rebind»: `assertPublicUrl` вызывается **ровно один раз — для исходного URL**, а browser-level фильтр хостнеймы **не резолвит вовсе** — сверяет только строки `localhost`/`*.local`/`*.internal` и литеральные IP. Значит без проверки уходит **любой новый хостнейм после первого перехода**: цель HTTP-редиректа, субресурс страницы, `fetch`/XHR из JS, `ws://`. Достаточно редиректа на внутреннее имя — DNS-rebind даже не требуется.
Закрывается это только на сетевом уровне. **Перед тем как поднять контейнер `browserless` на проде** (а не перед флагом `TOOLBOX_VISIBLE``/api/pdf` живёт независимо от него), нужно заблокировать исходящий трафик рендерера в `169.254.0.0/16` и RFC1918. Полный разбор — «Безопасность» и «Деплой» в [`docs/specs/20260706-clean-pdf-design.md`](docs/specs/20260706-clean-pdf-design.md) и процесс `SBT/02-Стандарты/Процессы/lms-deploy.md`.
---
@@ -0,0 +1,57 @@
# Чистый 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`).
+4 -2
View File
@@ -82,7 +82,7 @@ model PdfApiKey {
- Только `http://` и `https://`.
- Перед загрузкой — резолв DNS и блокировка приватных/зарезервированных диапазонов: localhost/127.x, 10.x, 172.1631.x, 192.168.x, 169.254.x (метаданные облаков), ::1, fc00::/7, плюс docker-хостнеймы стенда (`db`, `app`, `browserless`).
- Внутри browserless — перехват сетевых запросов страницы (`context.route`/`context.routeWebSocket`): блокирует буквальные приватные/зарезервированные IP и хосты `localhost`/`*.local`/`*.internal`. **Честно про предел этой защиты:** она не резолвит DNS заново — обычный хостнейм (не IP-литерал) проходит проверку без разрешения адреса. Значит, DNS-rebind на тот же хостнейм (первый резолв на этапе `assertPublicUrl` — публичный IP; повторный запрос со страницы внутри browserless — уже приватный IP того же имени) **не блокируется** этим browser-level фильтром. Остаточный риск закрывается на сетевом уровне — см. «Деплой».
- Внутри browserless — перехват сетевых запросов страницы (`context.route`/`context.routeWebSocket`): блокирует буквальные приватные/зарезервированные IP и хосты `localhost`/`*.local`/`*.internal`. **Честно про предел этой защиты:** `assertPublicUrl` резолвит DNS **ровно один раз — для исходного URL**, а browser-level фильтр хостнеймы не резолвит вовсе. Значит без проверки уходит **любой новый хостнейм после первого перехода**: цель редиректа, субресурс, `fetch` из JS, `ws://`. DNS-rebind для обхода даже не нужен — достаточно редиректа на внутреннее имя. Остаточный риск закрывается только на сетевом уровне — см. «Деплой». *(уточнено 20260912 аудитом кода)*
- Порт browserless наружу не публикуется, доступен только приложению по внутренней сети compose.
- Куки/учётные данные пользователя на целевую страницу не передаются.
- Санитайз имени файла в `Content-Disposition`.
@@ -97,7 +97,9 @@ model PdfApiKey {
- Стандартная схема деплоя LMS: сборка на Hetzner → `docker save | ssh | docker load` на Hoster.kz; browserless на Hoster.kz — обычный `docker pull`.
- Hot-standby на Hetzner получает тот же compose (репликация БД уже покрывает `PdfApiKey` и `ToolUsage`).
> ⚠️ **Перед включением `TOOLBOX_VISIBLE` на проде обязательно захардить сетевой egress контейнера `browserless`** — заблокировать `169.254.0.0/16` (cloud-metadata) и RFC1918-диапазоны (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) на уровне хост-файрвола или выделенной internal-only docker-сети. Именно это закрывает DNS-rebind остаточный риск, описанный выше в «Безопасность» — browser-level фильтр (`context.route`) его не закрывает, потому что не переразрешает хостнеймы.
> ⚠️ **Перед тем как поднять контейнер `browserless` на проде, обязательно захардить его сетевой egress** — заблокировать `169.254.0.0/16` (cloud-metadata) и RFC1918 (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) на уровне хост-файрвола (`DOCKER-USER`) или выделенной internal-only docker-сети.
>
> **Гейт привязан к рендереру, а не к флагу** *(уточнено 20260912)*: `TOOLBOX_VISIBLE` прячет только страницы `/tools/*`, а роут `/api/pdf` лежит в `PUBLIC_ROUTES` и авторизуется сам — он станет рабочим для любого платного студента в момент появления `browserless` и `BROWSER_WS_URL`, ещё до поднятия флага. Именно это закрывает остаточный SSRF-риск из раздела «Безопасность»: browser-level фильтр его не закрывает, потому что не переразрешает хостнеймы.
## Тестирование