feat(unfurl): превью ссылок с защитой от SSRF и кэшем в БД (Фаза 7)

Сервер сам загружает заголовок, описание и картинку страницы по ссылке из
сообщения и отдаёт клиенту готовую карточку.

Безопасность (главное здесь):
- только http/https и без userinfo; запрет петли, частных сетей, link-local
  (169.254.169.254), CGNAT, multicast и IPv4-mapped вариантов;
- проверка идёт по адресу, к которому реально открывается TCP
  (`net.Dialer.Control`), поэтому подмена DNS между проверкой и соединением
  (DNS rebinding) ничего не даёт;
- не больше 3 редиректов, каждый хоп проверяется заново; таймаут 5 с, тело
  ≤ 512 КБ, только `text/html`; прокси из окружения игнорируются, cookie и
  авторизация не отправляются; в логи попадают только хост и код причины;
- картинка по ссылке не скачивается — проверяется лишь её URL: экономия CPU на
  1 vCPU и минус класс атак через декодирование.

Кэш: таблица `link_previews` (миграция 00022, ключ — sha256 нормализованного
URL), TTL по статусу (ok — сутки, empty/blocked — час, error — 10 минут).
Ручка `GET /api/v1/link-previews?url=…` отвечает статусом
(ok/empty/blocked/error) и карточкой только при ok; 20 новых загрузок в минуту
на пользователя, кэшированные ответы лимит не тратят. `UNFURL_ENABLED=false`
выключает функцию целиком, `features.unfurl_enabled` виден в `/meta`. Retention
убирает истёкшие записи кэша.

Тесты: 21 в `internal/unfurl` (включая DNS rebinding через локальный
DNS-сервер, редирект во внутреннюю сеть, таймаут, лимиты размера и типа),
ручки, store и миграция.
This commit is contained in:
2026-09-26 16:15:00 +03:00
parent 7afd23d6d4
commit a9b1073877
15 changed files with 2168 additions and 4 deletions
+5
View File
@@ -58,6 +58,11 @@ VAPID_PRIVATE_KEY=
# Контакт администратора для push-сервисов (mailto:admin@<домен> или https://…).
VAPID_SUBJECT=
# --- превью ссылок (Фаза 7) ---
UNFURL_ENABLED=true # сервер сам загружает превью страниц по ссылкам
UNFURL_TIMEOUT_SECONDS=5 # таймаут загрузки превью
AGE_RECIPIENT= # публичный age-ключ для шифрования бэкапов
BACKUP_KEEP_DAYS= # сколько дней хранить бэкапы (профиль задаёт значение)
BACKUP_OFFSITE_TARGET= # необязательно: rsync-цель (user@host:/path) или rclone:remote:path