Files
grendervill a9b1073877 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 и миграция.
2026-09-26 16:15:00 +03:00

180 lines
5.7 KiB
Go

package unfurl
import (
"errors"
"fmt"
"io"
"net/netip"
"net/url"
"strings"
"golang.org/x/net/html"
)
// headMetadata — найденные в <head> значения по источникам. Приоритет
// разбирается в preview: og:* → twitter:* → обычные теги.
type headMetadata struct {
ogTitle string
twitterTitle string
title string
ogDescription string
twitterDescription string
description string
siteName string
ogImage string
twitterImage string
}
// parseHead разбирает <head> потоково и останавливается на </head>: тело
// страницы не читается. Читатель обычно ограничен LimitReader, поэтому
// обрыв по лимиту — не ошибка: возвращается всё, что успели найти.
func parseHead(reader io.Reader, base *url.URL, allowLoopback bool) (Preview, error) {
tokenizer := html.NewTokenizer(reader)
var meta headMetadata
inTitle := false
parse:
for {
switch tokenizer.Next() {
case html.ErrorToken:
// io.EOF — нормальное завершение (в том числе обрыв по лимиту).
if err := tokenizer.Err(); err != nil && !errors.Is(err, io.EOF) {
return meta.preview(base, allowLoopback), fmt.Errorf("чтение html: %w", err)
}
break parse
case html.CommentToken, html.DoctypeToken:
// Комментарии и doctype не содержат метаданных превью.
case html.StartTagToken, html.SelfClosingTagToken:
name, hasAttr := tokenizer.TagName()
switch string(name) {
case "title":
inTitle = true
case "meta":
// Атрибуты нужно вычитать до следующего Next.
meta.add(attributes(tokenizer, hasAttr))
}
case html.EndTagToken:
name, _ := tokenizer.TagName()
switch string(name) {
case "title":
inTitle = false
case "head":
break parse
}
case html.TextToken:
if inTitle {
// Токенизатор уже декодировал HTML-сущности.
meta.title += string(tokenizer.Text())
}
}
}
return meta.preview(base, allowLoopback), nil
}
// add раскладывает атрибуты <meta> по источникам. Ключи property/name
// сравниваются без учёта регистра, первое значение каждого источника
// выигрывает (повторы в разметке игнорируются).
func (m *headMetadata) add(attrs map[string]string) {
key := attrs["property"]
if key == "" {
key = attrs["name"]
}
content := attrs["content"]
if key == "" || content == "" {
return
}
switch strings.ToLower(strings.TrimSpace(key)) {
case "og:title":
setIfEmpty(&m.ogTitle, content)
case "twitter:title":
setIfEmpty(&m.twitterTitle, content)
case "og:description":
setIfEmpty(&m.ogDescription, content)
case "twitter:description":
setIfEmpty(&m.twitterDescription, content)
case "description":
setIfEmpty(&m.description, content)
case "og:site_name":
setIfEmpty(&m.siteName, content)
case "og:image":
setIfEmpty(&m.ogImage, content)
case "twitter:image":
setIfEmpty(&m.twitterImage, content)
}
}
// preview собирает Preview по приоритетам: og:* → twitter:* → обычные теги.
func (m headMetadata) preview(base *url.URL, allowLoopback bool) Preview {
image := m.ogImage
if image == "" {
image = m.twitterImage
}
return Preview{
Title: sanitize(firstNonEmpty(m.ogTitle, m.twitterTitle, m.title), titleLimit),
Description: sanitize(firstNonEmpty(m.ogDescription, m.twitterDescription, m.description), descriptionLimit),
SiteName: sanitize(m.siteName, siteNameLimit),
ImageURL: absoluteImageURL(image, base, allowLoopback),
}
}
// absoluteImageURL приводит ссылку на картинку к абсолютному виду относительно
// финального URL страницы. Картинка не скачивается: проверяется только сам URL
// (http/https и не внутренний адрес), иначе возвращается пустая строка.
func absoluteImageURL(raw string, base *url.URL, allowLoopback bool) string {
raw = strings.TrimSpace(raw)
if raw == "" || base == nil {
return ""
}
reference, err := url.Parse(raw)
if err != nil {
return ""
}
normalized, ok := Normalize(base.ResolveReference(reference).String())
if !ok {
return ""
}
parsed, err := url.Parse(normalized)
if err != nil {
return ""
}
host := parsed.Hostname()
// DNS для картинки не спрашиваем, но имя localhost отсекаем сразу.
lowerHost := strings.ToLower(host)
if lowerHost == "localhost" || strings.HasSuffix(lowerHost, ".localhost") {
return ""
}
if address, err := netip.ParseAddr(host); err == nil && blockedAddr(address, allowLoopback) {
return ""
}
return normalized
}
// attributes вычитывает атрибуты текущего тега в нижнем регистре.
func attributes(tokenizer *html.Tokenizer, hasAttr bool) map[string]string {
attrs := make(map[string]string, 4)
for hasAttr {
var key, value []byte
key, value, hasAttr = tokenizer.TagAttr()
name := strings.ToLower(string(key))
if _, exists := attrs[name]; !exists {
attrs[name] = string(value)
}
}
return attrs
}
func setIfEmpty(target *string, value string) {
if *target == "" {
*target = value
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}