diff --git a/TECHNICAL.md b/TECHNICAL.md index 603aad6..8e0d0e7 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -203,6 +203,8 @@ URL студента Контейнер `browserless` (`ghcr.io/browserless/chromium`) — внутренний, порт наружу не публикуется ни на staging, ни на проде. +**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). + --- ## Структура БД (ключевые таблицы) diff --git a/docs/specs/20260706-clean-pdf-design.md b/docs/specs/20260706-clean-pdf-design.md index 64a6c84..d2838d5 100644 --- a/docs/specs/20260706-clean-pdf-design.md +++ b/docs/specs/20260706-clean-pdf-design.md @@ -82,11 +82,12 @@ model PdfApiKey { - Только `http://` и `https://`. - Перед загрузкой — резолв DNS и блокировка приватных/зарезервированных диапазонов: localhost/127.x, 10.x, 172.16–31.x, 192.168.x, 169.254.x (метаданные облаков), ::1, fc00::/7, плюс docker-хостнеймы стенда (`db`, `app`, `browserless`). -- Внутри browserless — перехват сетевых запросов страницы с той же фильтрацией (защита от редиректов и подгрузок на внутренние адреса). +- Внутри browserless — перехват сетевых запросов страницы (`context.route`/`context.routeWebSocket`): блокирует буквальные приватные/зарезервированные IP и хосты `localhost`/`*.local`/`*.internal`. **Честно про предел этой защиты:** она не резолвит DNS заново — обычный хостнейм (не IP-литерал) проходит проверку без разрешения адреса. Значит, DNS-rebind на тот же хостнейм (первый резолв на этапе `assertPublicUrl` — публичный IP; повторный запрос со страницы внутри browserless — уже приватный IP того же имени) **не блокируется** этим browser-level фильтром. Остаточный риск закрывается на сетевом уровне — см. «Деплой». - Порт browserless наружу не публикуется, доступен только приложению по внутренней сети compose. - Куки/учётные данные пользователя на целевую страницу не передаются. - Санитайз имени файла в `Content-Disposition`. - Лимиты: 100/мес (env) + burst 5/мин на пользователя; сверху — очередь browserless (2 конкурентных рендера). +- ⚠️ Follow-up (не блокирует релиз): оба счётчика лимита считают только **успешные** генерации (`ToolUsage` пишется после успешного рендера) — неудачные попытки (таймаут, 5xx с целевого сайта, зависший рендер) лимит не расходуют. Потенциальный ограниченный self-DoS повторными запросами к тяжёлым/неотвечающим URL. Рассмотреть подсчёт попыток, а не только успехов, отдельной задачей. ## Деплой @@ -96,6 +97,8 @@ 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`) его не закрывает, потому что не переразрешает хостнеймы. + ## Тестирование - Юнит: SSRF-валидатор (таблица адресов → допуск/блок), пайплайн Defuddle → HTML-шаблон на фикстурах. diff --git a/docs/superpowers/plans/20260706-clean-pdf.md b/docs/superpowers/plans/20260706-clean-pdf.md index 73659b7..3ae2c2e 100644 --- a/docs/superpowers/plans/20260706-clean-pdf.md +++ b/docs/superpowers/plans/20260706-clean-pdf.md @@ -1654,9 +1654,13 @@ git commit -m "Add browserless service to prod compose and document clean-pdf" 1. В `.env` на Hoster.kz добавить `BROWSERLESS_TOKEN=` (например `openssl rand -hex 24`) и при желании `PDF_MONTHLY_LIMIT`. 2. Миграция на проде: выполняется как обычно при деплое (`prisma migrate deploy` в entrypoint — проверить, что `add_pdf_api_key` применилась: `docker exec -i lms-sb-db-1 psql -U lms_user -d lms_db -c '\d "PdfApiKey"'`). 3. `docker pull ghcr.io/browserless/chromium` на Hoster.kz (интернет с сервера есть) + обновлённый compose. + +> ⚠️ **Перед шагом 4 (включением `TOOLBOX_VISIBLE=true`) обязательно захардить сетевой 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-сети. Browser-level SSRF-фильтр (`context.route`/`context.routeWebSocket`) не резолвит DNS заново и не закрывает same-hostname DNS-rebind (публичный IP на первом резолве, приватный — на повторном запросе изнутри browserless); сетевой egress-блок — единственный слой, который закрывает этот остаточный риск. Подробности — «Безопасность»/«Деплой» в `docs/specs/20260706-clean-pdf-design.md`. + 4. Инструмент останется невидимым, пока на проде не выставлен `TOOLBOX_VISIBLE=true` — это отдельное продуктовое решение. `/api/pdf` при этом уже будет работать — это ок (доступ гейтится платным enrollment), но анонсировать до включения тулбокса не нужно. 5. Hot-standby Hetzner: подтянуть тот же compose (`/root/digital-household/lms-sb/docker-compose.prod.yml` обновится через git pull). 6. После релиза: обновить SBT-карточку `SBT/00-Стек/Сервисы/lms.md` (раздел про тулбокс + новые env) через capture-knowledge. +7. Follow-up (не блокирует релиз): месячный/burst-лимит сейчас считает только **успешные** генерации — неудачные попытки (таймаут, 5xx, зависший рендер) лимит не расходуют, потенциальный ограниченный self-DoS повторными запросами. Рассмотреть подсчёт попыток, а не только успехов, отдельной задачей. ## Самопроверка при завершении