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
+218
View File
@@ -0,0 +1,218 @@
package server
import (
"context"
"errors"
"log/slog"
"net/http"
"net/url"
"time"
"github.com/danielgtaylor/huma/v2"
"glchat/internal/config"
"glchat/internal/store"
"glchat/internal/unfurl"
)
// Unfurl ссылок (AGENT.md Фаза 7): сервер загружает заголовок, описание и
// картинку страницы по ссылке из сообщения и отдаёт клиенту карточку превью.
//
// Безопасность важнее полноты: загрузка идёт через internal/unfurl (только
// http/https, запрет внутренних адресов с проверкой на подключении, лимиты
// времени и размера, только HTML). Картинка на сервер не скачивается — клиент
// берёт её по проверенному URL с `referrerpolicy=no-referrer`.
//
// Кэш живёт в БД (`link_previews`): успешные превью — на сутки, «пустые» и
// запрещённые — на час, сбои — на десять минут, чтобы не долбить чужой сайт.
// Время жизни записей кэша по статусу.
const (
unfurlTTLOK = 24 * time.Hour
unfurlTTLEmpty = time.Hour
unfurlTTLBlocked = time.Hour
unfurlTTLError = 10 * time.Minute
)
// linkPreviewer загружает и кэширует превью ссылок.
type linkPreviewer struct {
enabled bool
fetcher *unfurl.Fetcher
store *store.Store
now func() time.Time
}
// newLinkPreviewer собирает загрузчик по конфигу: выключенный unfurl не
// создаёт ни клиента, ни фоновых задач.
func newLinkPreviewer(cfg config.Config, st *store.Store, logger *slog.Logger) *linkPreviewer {
return &linkPreviewer{
enabled: cfg.UnfurlEnabled,
fetcher: unfurl.NewFetcher(unfurl.Options{
Timeout: cfg.UnfurlTimeout,
Logger: logger,
}),
store: st,
now: func() time.Time { return time.Now().UTC() },
}
}
// linkPreviewPayload — карточка превью для клиента.
type linkPreviewPayload struct {
URL string `json:"url"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
SiteName string `json:"site_name,omitempty"`
ImageURL string `json:"image_url,omitempty"`
}
type linkPreviewOutput struct {
Body struct {
// Status: ok (карточка есть), empty (метаданных нет), blocked (адрес
// запрещён), error (не удалось загрузить). Карточка показывается
// только при ok.
Status string `json:"status"`
Preview *linkPreviewPayload `json:"preview,omitempty"`
}
}
// registerUnfurlRoutes описывает ручку превью ссылок.
func (s *Server) registerUnfurlRoutes(api huma.API) {
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
huma.Register(api, huma.Operation{
OperationID: "getLinkPreview",
Method: http.MethodGet,
Path: "/link-previews",
Summary: "Карточка превью по ссылке из сообщения",
Tags: []string{"Unfurl"},
Security: security,
}, func(ctx context.Context, input *struct {
URL string `query:"url" required:"true" maxLength:"2048"`
},
) (*linkPreviewOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
if !s.unfurl.enabled {
return nil, humaErrorStatus(http.StatusServiceUnavailable, "unfurl.disabled",
"link previews are disabled on this instance")
}
output := &linkPreviewOutput{}
status, preview, err := s.linkPreview(ctx, user.ID, input.URL)
if err != nil {
return nil, err
}
output.Body.Status = status
output.Body.Preview = preview
return output, nil
})
}
// linkPreview отдаёт превью из кэша или загружает его. Статусы совпадают с
// тем, что видит клиент: ok, empty, blocked, error.
func (s *Server) linkPreview(ctx context.Context, userID uint64, rawURL string) (string, *linkPreviewPayload, error) {
normalized, ok := unfurl.Normalize(rawURL)
if !ok {
// Сюда попадают не только «плохие» адреса, но и file://, data: и
// прочие схемы: клиент карточку не показывает, ошибки не показывает тем
// более — просто «нет превью».
return store.LinkPreviewStatusBlocked, nil, nil
}
key := unfurl.URLHash(normalized)
cached, err := s.store.GetLinkPreview(ctx, key)
if err == nil {
return linkPreviewFromCache(cached)
}
if !errors.Is(err, store.ErrNotFound) {
return "", nil, humaError(err)
}
// Новый запрос к чужому сайту — только под лимитом: кэш повторные
// обращения не считает, иначе один пользователь может гонять чужие
// страницы пачками (AGENT.md 8.6).
if allowed, retryAfter := s.unfurlLimiter.Allow(pushLimitKey(userID)); !allowed {
return "", nil, rateLimitedError(retryAfter)
}
preview, fetchErr := s.unfurl.fetcher.Fetch(ctx, normalized)
status := store.LinkPreviewStatusError
switch {
case fetchErr == nil && preview.Empty():
status = store.LinkPreviewStatusEmpty
case fetchErr == nil:
status = store.LinkPreviewStatusOK
case errors.Is(fetchErr, unfurl.ErrBlocked), errors.Is(fetchErr, unfurl.ErrUnsupported):
status = store.LinkPreviewStatusBlocked
}
if fetchErr != nil && status == store.LinkPreviewStatusError {
s.logger.WarnContext(ctx, "link preview failed",
slog.String("host", hostOf(normalized)), slog.Any("error", fetchErr))
}
// Ошибку кэша не поднимаем наверх: превью уже получено (или честно
// отсутствует), а запись в кэш — оптимизация.
now := s.unfurl.now()
params := store.SaveLinkPreviewParams{
URLHash: key,
URL: normalized,
Title: preview.Title,
Description: preview.Description,
SiteName: preview.SiteName,
ImageURL: preview.ImageURL,
Status: status,
FetchedAt: now,
ExpiresAt: now.Add(unfurlTTLFor(status)),
}
if err := s.store.SaveLinkPreview(ctx, params); err != nil {
s.logger.WarnContext(ctx, "link preview cache write failed", slog.Any("error", err))
}
if status != store.LinkPreviewStatusOK {
return status, nil, nil
}
return status, &linkPreviewPayload{
URL: normalized,
Title: preview.Title,
Description: preview.Description,
SiteName: preview.SiteName,
ImageURL: preview.ImageURL,
}, nil
}
// linkPreviewFromCache переводит запись кэша в ответ клиенту.
func linkPreviewFromCache(cached *store.LinkPreview) (string, *linkPreviewPayload, error) {
if cached.Status != store.LinkPreviewStatusOK {
return cached.Status, nil, nil
}
return cached.Status, &linkPreviewPayload{
URL: cached.URL,
Title: cached.Title,
Description: cached.Description,
SiteName: cached.SiteName,
ImageURL: cached.ImageURL,
}, nil
}
// unfurlTTLFor выбирает срок жизни записи кэша по статусу.
func unfurlTTLFor(status string) time.Duration {
switch status {
case store.LinkPreviewStatusOK:
return unfurlTTLOK
case store.LinkPreviewStatusEmpty:
return unfurlTTLEmpty
case store.LinkPreviewStatusBlocked:
return unfurlTTLBlocked
default:
return unfurlTTLError
}
}
// hostOf достаёт хост для логов: полный URL с query туда не попадает.
func hostOf(normalized string) string {
parsed, err := url.Parse(normalized)
if err != nil {
return ""
}
return parsed.Hostname()
}