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:
@@ -78,6 +78,10 @@ type Server struct {
|
||||
pushNotifyLimiter *httpx.RateLimiter
|
||||
// push отправляет Web Push; nil, если ключи не настроены.
|
||||
push pushSender
|
||||
// unfurl загружает и кэширует превью ссылок (Фаза 7), unfurlLimiter
|
||||
// ограничивает новые загрузки (кэш лимит не расходует).
|
||||
unfurl *linkPreviewer
|
||||
unfurlLimiter *httpx.RateLimiter
|
||||
// slowmode — время последней отправки в комнату для режима медленной
|
||||
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
||||
slowmodeMu sync.Mutex
|
||||
@@ -138,6 +142,9 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
passkeyLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||||
oauthLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||||
pushLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||||
// Новые превью ссылок: 20 в минуту на пользователя (кэшированные
|
||||
// ответы лимит не тратят).
|
||||
unfurlLimiter: httpx.NewRateLimiterWindow(20, time.Minute, 20),
|
||||
// Уведомления на получателя: 20 в минуту с запасом 20 — массовые
|
||||
// упоминания не должны заваливать устройство.
|
||||
pushNotifyLimiter: httpx.NewRateLimiter(20, 20),
|
||||
@@ -158,6 +165,11 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
case deps.Store != nil:
|
||||
s.perms = permissions.NewCalculator(source.New(deps.Store))
|
||||
}
|
||||
// Превью ссылок (Фаза 7): загрузчик создаётся всегда, а выключенный
|
||||
// unfurl просто отвечает `unfurl.disabled` и в интернет не ходит.
|
||||
if deps.Store != nil {
|
||||
s.unfurl = newLinkPreviewer(cfg, deps.Store, logger)
|
||||
}
|
||||
// Web Push (Фаза 7): неверный VAPID-ключ не должен мешать инстансу
|
||||
// подняться — пишем предупреждение и работаем без push.
|
||||
if deps.Store != nil {
|
||||
@@ -204,6 +216,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
s.registerInviteBackgroundRoutes(apiRouter)
|
||||
s.registerVoiceWebhook(apiRouter)
|
||||
s.registerPushRoutes(s.api)
|
||||
s.registerUnfurlRoutes(s.api)
|
||||
}
|
||||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user