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
+12 -2
View File
@@ -42,10 +42,12 @@ type Stats struct {
Bytes int64
// PushSubscriptions — мёртвые подписки Web Push (Фаза 7).
PushSubscriptions int64
// LinkPreviews — истёкшие записи кэша превью ссылок (Фаза 7).
LinkPreviews int64
}
// Мёртвые подписки Web Push (Фаза 7): подписка, которая не доставлялась
// неделю и накопила неудачи, больше не нужна.
// Мёртвые подписки Web Push и кэш превью ссылок (Фаза 7): подписка, которая
// не доставлялась неделю и накопила неудачи, и протухшее превью не нужны.
const (
stalePushAfterDays = 7
stalePushMaxFailures = 5
@@ -136,6 +138,13 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
}
stats.PushSubscriptions = removed
// 6. Истёкший кэш превью ссылок: перезапрашивать его незачем.
previews, err := r.store.DeleteExpiredLinkPreviews(ctx, r.now())
if err != nil {
errs = append(errs, fmt.Errorf("link previews: %w", err))
}
stats.LinkPreviews = previews
stats2 := stats
if len(errs) > 0 {
return stats2, errors.Join(errs...)
@@ -254,6 +263,7 @@ func (r *Runner) Run(ctx context.Context, interval time.Duration) func() {
slog.Int64("files", stats.Files),
slog.Int64("unknown_files", stats.Unknown),
slog.Int64("push_subscriptions", stats.PushSubscriptions),
slog.Int64("link_previews", stats.LinkPreviews),
slog.Int64("bytes", stats.Bytes))
}
}