Document DNS-rebind residual and prod-enablement egress hardening
The security docs claimed the in-browser SSRF filter (context.route/ routeWebSocket) re-applies "the same filtering" as the pre-fetch DNS check. That's inaccurate for hostnames: the browser-level filter only blocks literal private IPs and localhost/.local/.internal suffixes — it never re-resolves hostnames, so a same-hostname DNS-rebind (public IP on first resolve, private IP on a later request from inside browserless) is not closed at that layer. Correct the wording in the design spec and TECHNICAL.md, and add a prominent note to both the spec's deploy section and the plan's deploy notes: before flipping TOOLBOX_VISIBLE on prod, harden the browserless container's network egress (block 169.254.0.0/16 and RFC1918 ranges via host firewall or a dedicated internal docker network) to close the residual at the network layer. Also note that per-user limits currently count only successful generations — failed renders are uncapped, a bounded self-DoS risk worth a follow-up.
This commit is contained in:
@@ -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-шаблон на фикстурах.
|
||||
|
||||
Reference in New Issue
Block a user