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
+179
View File
@@ -0,0 +1,179 @@
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 ""
}
+482
View File
@@ -0,0 +1,482 @@
// Package unfurl загружает серверные превью ссылок (unfurl, фаза 7 AGENT.md):
// заголовок, описание, имя сайта и картинку из <head> страницы.
//
// Пакет намеренно недоверчив к внешнему миру:
// - только схемы http/https, без userinfo;
// - запрет внутренних адресов, причём проверяется именно тот адрес, куда
// идёт TCP (net.Dialer.Control) — это защищает от DNS rebinding;
// - не больше трёх редиректов, каждый хоп проверяется заново;
// - общий таймаут, ограничение размера тела и только text/html;
// - картинка по ссылке не скачивается: проверяется лишь её URL.
//
// Страница без метаданных — не ошибка: Fetch возвращает Preview с пустыми
// полями и nil. Ошибка означает, что превью получить нельзя (запрещённый
// адрес, не тот тип содержимого, таймаут, слишком большой ответ). Это и есть
// способ отличить «страница без превью» (status "empty") от сбоя (status
// "blocked"/"error") на стороне HTTP-слоя.
package unfurl
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"mime"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"syscall"
"time"
"unicode"
)
// Значения по умолчанию для Options.
const (
defaultTimeout = 5 * time.Second
defaultMaxBytes = 512 << 10 // 512 KiB
defaultUserAgent = "glchat/1.0 (+link preview)"
)
// maxRedirects — сколько переходов разрешено отследить (AGENT.md: не больше 3).
const maxRedirects = 3
// Ограничения на текст превью (в рунах) и заголовки запроса.
const (
titleLimit = 200
descriptionLimit = 400
siteNameLimit = 100
acceptHTML = "text/html,application/xhtml+xml"
maxResponseHeaderBytes = 64 << 10
)
var (
// ErrUnsupported — URL не поддержан: чужая схема, userinfo, пустой хост.
ErrUnsupported = errors.New("unsupported url")
// ErrBlocked — адрес запрещён: петля, частная сеть, link-local и т.п.
ErrBlocked = errors.New("address is not allowed")
// ErrTooLarge — ответ заведомо больше MaxBytes (по Content-Length).
ErrTooLarge = errors.New("response is too large")
// ErrContentType — сервер отдал не HTML.
ErrContentType = errors.New("unsupported content type")
)
// errTooManyRedirects — внутренняя ошибка: цепочка переходов длиннее лимита.
// Наружу приходит обёрнутой в *url.Error, проверяется через errors.Is.
var errTooManyRedirects = errors.New("too many redirects")
// Preview — то, что показываем клиенту.
type Preview struct {
URL string // нормализованный URL (как в запросе)
Title string
Description string
SiteName string
ImageURL string // абсолютный http(s) URL или ""
}
// Empty сообщает, что метаданных не нашлось: клиенту показывать нечего,
// но и ошибкой это не является.
func (p Preview) Empty() bool {
return p.Title == "" && p.Description == "" && p.SiteName == "" && p.ImageURL == ""
}
// Options — настройки загрузчика превью.
type Options struct {
Timeout time.Duration // по умолчанию 5s
MaxBytes int64 // по умолчанию 512 KiB
UserAgent string // по умолчанию "glchat/1.0 (+link preview)"
Logger *slog.Logger // может быть nil
// AllowPrivate ослабляет проверку адресов для тестов: разрешает петлевые
// адреса (127.0.0.0/8, ::1), на которых поднимаются httptest-серверы.
// Частные сети, link-local (в том числе метаданные облака), multicast и
// unspecified остаются запрещёнными и в этом режиме — «если сомневаешься,
// не тяни ресурс». В продакшене флаг всегда false.
AllowPrivate bool
// Resolver — подменяемый резолвер DNS (тесты); nil — системный.
Resolver *net.Resolver
}
// Fetcher загружает превью ссылок. Безопасен для параллельного использования.
type Fetcher struct {
client *http.Client
timeout time.Duration
maxBytes int64
userAgent string
logger *slog.Logger
allowPrivate bool
}
// NewFetcher собирает загрузчик с безопасным HTTP-клиентом: без прокси из
// окружения, без cookie и авторизации, с проверкой адреса перед подключением.
func NewFetcher(opts Options) *Fetcher {
timeout := opts.Timeout
if timeout <= 0 {
timeout = defaultTimeout
}
maxBytes := opts.MaxBytes
if maxBytes <= 0 {
maxBytes = defaultMaxBytes
}
userAgent := strings.TrimSpace(opts.UserAgent)
if userAgent == "" {
userAgent = defaultUserAgent
}
fetcher := &Fetcher{
timeout: timeout,
maxBytes: maxBytes,
userAgent: userAgent,
logger: opts.Logger,
allowPrivate: opts.AllowPrivate,
}
dialer := &net.Dialer{
Timeout: timeout,
KeepAlive: -1,
// Control вызывается после разрешения имени и до TCP-подключения,
// поэтому проверяется реально подключаемый адрес.
Control: fetcher.control,
}
if opts.Resolver != nil {
dialer.Resolver = opts.Resolver
}
fetcher.client = &http.Client{
Transport: &http.Transport{
// Прокси из окружения игнорируем: иначе проверка адреса
// обходится через HTTP_PROXY, а запрос уходит не туда.
Proxy: nil,
DialContext: dialer.DialContext,
TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout,
ExpectContinueTimeout: time.Second,
MaxResponseHeaderBytes: maxResponseHeaderBytes,
DisableKeepAlives: true,
ForceAttemptHTTP2: false,
},
CheckRedirect: fetcher.checkRedirect,
}
return fetcher
}
// Fetch загружает страницу и разбирает метаданные её <head>. Тело читается не
// целиком: разбор прекращается на </head> или по достижении MaxBytes. Если
// тело оборвано лимитом, но часть метаданных уже разобрана, они возвращаются
// без ошибки. Ошибка возвращается, только когда превью получить не удалось.
func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (Preview, error) {
normalized, ok := Normalize(rawURL)
if !ok {
// Хоста может не быть вовсе (file://, javascript:), поэтому в логе
// только код причины.
f.logWarn("", reasonUnsupported)
return Preview{}, fmt.Errorf("unfurl: %w", ErrUnsupported)
}
parsed, err := url.Parse(normalized)
if err != nil {
// Normalize уже разобрал URL, сюда попасть нельзя.
return Preview{}, fmt.Errorf("unfurl: %w", ErrUnsupported)
}
host := parsed.Hostname()
// IP-литералы отсекаем сразу: до DNS, соединения и редиректов.
if address, err := netip.ParseAddr(host); err == nil && blockedAddr(address, f.allowPrivate) {
f.logWarn(host, reasonBlocked)
return Preview{}, fmt.Errorf("unfurl %s: %w", host, ErrBlocked)
}
ctx, cancel := context.WithTimeout(ctx, f.timeout)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, normalized, nil)
if err != nil {
return Preview{}, fmt.Errorf("unfurl %s: create request: %w", host, err)
}
// Только эти заголовки: ни cookie, ни авторизации, ни Referer.
request.Header.Set("User-Agent", f.userAgent)
request.Header.Set("Accept", acceptHTML)
response, err := f.client.Do(request)
if err != nil {
// Ошибки Control и CheckRedirect уже несут нужные sentinel-ошибки.
cause := stripURL(err)
f.logWarn(host, reasonOf(cause))
return Preview{}, fmt.Errorf("unfurl %s: %w", host, cause)
}
defer response.Body.Close()
if response.ContentLength > f.maxBytes {
f.logWarn(host, reasonTooLarge)
return Preview{}, fmt.Errorf("unfurl %s: %w", host, ErrTooLarge)
}
if err := checkContentType(response.Header.Get("Content-Type")); err != nil {
f.logWarn(host, reasonContentType)
return Preview{}, fmt.Errorf("unfurl %s: %w", host, err)
}
// База для относительных ссылок — финальный URL после редиректов.
base := response.Request.URL
if base == nil {
base = parsed
}
preview, err := parseHead(io.LimitReader(response.Body, f.maxBytes), base, f.allowPrivate)
preview.URL = normalized
if err != nil {
if !preview.Empty() {
// Часть данных успела разобраться — отдаём её, а сбой только логируем.
f.logDebug(host, reasonParseFailed)
return preview, nil
}
f.logWarn(host, reasonParseFailed)
return Preview{}, fmt.Errorf("unfurl %s: %w", host, err)
}
f.logDebug(host, reasonOK)
return preview, nil
}
// Коды причин для логов: полный URL с query в логи не попадает, остаётся
// только хост и понятная причина (приватность, AGENT.md 9.7).
const (
reasonOK = "ok"
reasonBlocked = "blocked"
reasonUnsupported = "unsupported"
reasonTooLarge = "too_large"
reasonContentType = "content_type"
reasonTimeout = "timeout"
reasonCanceled = "canceled"
reasonRedirect = "redirect"
reasonParseFailed = "parse_failed"
reasonRequest = "request_failed"
)
// reasonOf сводит ошибку к короткому коду для лога.
func reasonOf(err error) string {
switch {
case err == nil:
return reasonOK
case errors.Is(err, ErrBlocked):
return reasonBlocked
case errors.Is(err, ErrUnsupported):
return reasonUnsupported
case errors.Is(err, ErrTooLarge):
return reasonTooLarge
case errors.Is(err, ErrContentType):
return reasonContentType
case errors.Is(err, errTooManyRedirects):
return reasonRedirect
case errors.Is(err, context.Canceled):
return reasonCanceled
case errors.Is(err, context.DeadlineExceeded):
return reasonTimeout
default:
return reasonRequest
}
}
// stripURL убирает из ошибки http.Client обёртку *url.Error: она содержит
// полный URL с query, а он не должен попадать ни в логи, ни к вызывающему
// коду. Sentinel-ошибки внутри сохраняются, errors.Is продолжает работать.
func stripURL(err error) error {
for {
var urlErr *url.Error
if !errors.As(err, &urlErr) || urlErr.Err == nil || errors.Is(urlErr.Err, err) {
return err
}
err = urlErr.Err
}
}
// Normalize проверяет и нормализует URL: только http/https, без userinfo,
// хост в нижнем регистре, без фрагмента, пустой путь — "/". false — URL не
// поддержан (чужая схема, userinfo, пустой хост).
func Normalize(rawURL string) (string, bool) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return "", false
}
if parsed.Opaque != "" {
// "http:example.com" и подобные формы без "//" не поддержаны.
return "", false
}
switch strings.ToLower(parsed.Scheme) {
case "http", "https":
default:
return "", false
}
if parsed.User != nil {
return "", false
}
if parsed.Host == "" || parsed.Hostname() == "" {
return "", false
}
parsed.Scheme = strings.ToLower(parsed.Scheme)
parsed.Host = strings.ToLower(parsed.Host)
parsed.Fragment = ""
parsed.RawFragment = ""
if parsed.Path == "" {
parsed.Path = "/"
}
return parsed.String(), true
}
// URLHash — sha256 (hex) от нормализованного URL: ключ кэша.
func URLHash(normalized string) string {
sum := sha256.Sum256([]byte(normalized))
return hex.EncodeToString(sum[:])
}
// control проверяет адрес, к которому реально идёт TCP-соединение. Вызывается
// net.Dialer после разрешения имени (и для каждого адреса из ответа DNS) и до
// подключения, поэтому подмена DNS между проверкой и соединением невозможна.
func (f *Fetcher) control(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("%w: некорректный адрес подключения", ErrBlocked)
}
ip, err := netip.ParseAddr(host)
if err != nil {
return fmt.Errorf("%w: некорректный адрес подключения", ErrBlocked)
}
if blockedAddr(ip, f.allowPrivate) {
return fmt.Errorf("%w: %s", ErrBlocked, ip)
}
return nil
}
// checkRedirect проверяет каждый хоп: схему, хост и лимит переходов. Адрес
// дополнительно проверяет control при подключении к новому хосту.
func (f *Fetcher) checkRedirect(request *http.Request, via []*http.Request) error {
if len(via) > maxRedirects {
return fmt.Errorf("unfurl: %w (больше %d)", errTooManyRedirects, maxRedirects)
}
if _, ok := Normalize(request.URL.String()); !ok {
return fmt.Errorf("unfurl: %w (редирект)", ErrUnsupported)
}
if address, err := netip.ParseAddr(request.URL.Hostname()); err == nil && blockedAddr(address, f.allowPrivate) {
return fmt.Errorf("unfurl: %w (редирект)", ErrBlocked)
}
// Go копирует заголовки при переходе, но задаём их явно: так поведение
// не зависит от версии стандартной библиотеки.
request.Header.Set("User-Agent", f.userAgent)
request.Header.Set("Accept", acceptHTML)
return nil
}
// blockedAddr сообщает, запрещён ли адрес для подключения. allowLoopback
// разрешает только петлевые адреса — этого достаточно httptest-серверам.
// IPv4-mapped IPv6 приводятся к IPv4, поэтому ::ffff:10.0.0.1 не проскочит.
func blockedAddr(ip netip.Addr, allowLoopback bool) bool {
if !ip.IsValid() {
return true
}
ip = ip.Unmap()
if ip.Is4() {
octets := ip.As4()
switch {
case octets[0] == 127: // 127.0.0.0/8 — петля
return !allowLoopback
case octets[0] == 0: // 0.0.0.0/8, включая 0.0.0.0
return true
case octets[0] == 10: // 10.0.0.0/8
return true
case octets[0] == 172 && octets[1]&0xf0 == 16: // 172.16.0.0/12
return true
case octets[0] == 192 && octets[1] == 168: // 192.168.0.0/16
return true
case octets[0] == 169 && octets[1] == 254: // 169.254.0.0/16, метаданные облака
return true
case octets[0] == 100 && octets[1]&0xc0 == 64: // 100.64.0.0/10 (CGNAT)
return true
case octets[0] == 192 && octets[1] == 0 && octets[2] == 0: // 192.0.0.0/24
return true
case octets[0] == 198 && (octets[1] == 18 || octets[1] == 19): // 198.18.0.0/15
return true
case octets[0] >= 224: // multicast, 240.0.0.0/4 и широковещательный
return true
}
return false
}
switch {
case ip.IsLoopback(): // ::1
return !allowLoopback
case ip.IsPrivate(), // fc00::/7
ip.IsLinkLocalUnicast(), // fe80::/10
ip.IsLinkLocalMulticast(),
ip.IsInterfaceLocalMulticast(),
ip.IsMulticast(),
ip.IsUnspecified(): // ::
return true
}
return false
}
// checkContentType пропускает только HTML: charset и прочие параметры
// игнорируются, пустой или неразбираемый Content-Type — ошибка.
func checkContentType(value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%w: пустой Content-Type", ErrContentType)
}
mediaType, _, err := mime.ParseMediaType(value)
if err != nil {
return fmt.Errorf("%w: %w", ErrContentType, err)
}
switch strings.ToLower(mediaType) {
case "text/html", "application/xhtml+xml":
return nil
default:
return fmt.Errorf("%w: %s", ErrContentType, mediaType)
}
}
// sanitize убирает управляющие символы, схлопывает пробелы и переводы строк,
// обрезает значение до limit рун. Пустое значение остаётся пустым.
func sanitize(value string, limit int) string {
collapsed := strings.Map(func(r rune) rune {
switch r {
case '\t', '\n', '\v', '\f', '\r':
return ' '
}
if unicode.IsControl(r) {
return -1
}
return r
}, value)
collapsed = strings.Join(strings.Fields(collapsed), " ")
return truncateRunes(collapsed, limit)
}
// truncateRunes обрезает строку до limit рун, не разрывая UTF-8.
func truncateRunes(value string, limit int) string {
if limit <= 0 {
return ""
}
count := 0
for index := range value {
if count == limit {
return value[:index]
}
count++
}
return value
}
// logDebug и logWarn пишут только хост и код причины: полный URL с query в
// логи не попадает (приватность), а уровень выбирается по исходу запроса.
func (f *Fetcher) logDebug(host, reason string) {
if f.logger == nil {
return
}
f.logger.Debug("unfurl: превью разобрано", slog.String("host", host), slog.String("reason", reason))
}
func (f *Fetcher) logWarn(host, reason string) {
if f.logger == nil {
return
}
f.logger.Warn("unfurl: превью недоступно", slog.String("host", host), slog.String("reason", reason))
}
+664
View File
@@ -0,0 +1,664 @@
package unfurl
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"os"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"unicode/utf8"
"golang.org/x/net/dns/dnsmessage"
)
// testFetcher собирает загрузчик для httptest-серверов: они живут на петле,
// поэтому без AllowPrivate адрес 127.0.0.1 был бы запрещён.
func testFetcher(t *testing.T, mutate func(*Options)) *Fetcher {
t.Helper()
options := Options{Timeout: 2 * time.Second, AllowPrivate: true}
if mutate != nil {
mutate(&options)
}
return NewFetcher(options)
}
// serveHTML поднимает сервер, отдающий заданный Content-Type и тело.
func serveHTML(t *testing.T, contentType string, body string) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
_, _ = io.WriteString(w, body)
}))
t.Cleanup(server.Close)
return server
}
func TestNormalize(t *testing.T) {
cases := []struct {
name string
raw string
want string
ok bool
}{
{"регистр хоста и фрагмент", "https://Example.COM/Path?q=1#frag", "https://example.com/Path?q=1", true},
{"пустой путь", "http://example.com", "http://example.com/", true},
{"порт сохраняется", "http://example.com:8080/a", "http://example.com:8080/a", true},
{"пробелы по краям", " https://example.com/a ", "https://example.com/a", true},
{"ipv6-литерал", "http://[::1]:8080/x", "http://[::1]:8080/x", true},
{"file", "file:///etc/passwd", "", false},
{"javascript", "javascript:alert(1)", "", false},
{"data", "data:text/html,<h1>x</h1>", "", false},
{"ftp", "ftp://example.com/x", "", false},
{"gopher", "gopher://example.com/", "", false},
{"userinfo", "http://user:pass@example.com/", "", false},
{"пустой хост", "http://", "", false},
{"пустая строка", "", "", false},
{"относительная ссылка", "/only/path", "", false},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
got, ok := Normalize(testCase.raw)
if ok != testCase.ok {
t.Fatalf("Normalize(%q) ok = %v, want %v", testCase.raw, ok, testCase.ok)
}
if got != testCase.want {
t.Errorf("Normalize(%q) = %q, want %q", testCase.raw, got, testCase.want)
}
})
}
}
func TestURLHash(t *testing.T) {
first := URLHash("https://example.com/a")
second := URLHash("https://example.com/a")
other := URLHash("https://example.com/b")
if len(first) != 64 {
t.Errorf("длина хэша = %d, want 64", len(first))
}
if strings.Trim(first, "0123456789abcdef") != "" {
t.Errorf("хэш %q не является hex-строкой", first)
}
if first != second {
t.Errorf("хэш не детерминирован: %q != %q", first, second)
}
if first == other {
t.Errorf("разные URL дали одинаковый хэш %q", first)
}
}
func TestFetchOpenGraph(t *testing.T) {
const page = `<!doctype html>
<html><head>
<meta property="og:title" content="Заголовок &amp; сущность">
<meta property="OG:DESCRIPTION" content="Описание страницы">
<meta property="og:site_name" content="Пример">
<meta property="og:image" content="/img/pic.png">
</head><body>тело</body></html>`
var sawRequest atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("User-Agent"); !strings.HasPrefix(got, defaultUserAgent) {
t.Errorf("User-Agent = %q, want префикс %q", got, defaultUserAgent)
}
if got := r.Header.Get("Accept"); got != acceptHTML {
t.Errorf("Accept = %q, want %q", got, acceptHTML)
}
if got := r.Header.Get("Cookie"); got != "" {
t.Errorf("запрос ушёл с Cookie: %q", got)
}
if got := r.Header.Get("Authorization"); got != "" {
t.Errorf("запрос ушёл с Authorization: %q", got)
}
sawRequest.Store(true)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, page)
}))
t.Cleanup(server.Close)
preview, err := testFetcher(t, nil).Fetch(context.Background(), server.URL+"/page")
if err != nil {
t.Fatalf("Fetch() вернул ошибку: %v", err)
}
if !sawRequest.Load() {
t.Fatal("сервер не получил запрос")
}
if preview.URL != server.URL+"/page" {
t.Errorf("URL = %q, want %q", preview.URL, server.URL+"/page")
}
if preview.Title != "Заголовок & сущность" {
t.Errorf("Title = %q", preview.Title)
}
if preview.Description != "Описание страницы" {
t.Errorf("Description = %q", preview.Description)
}
if preview.SiteName != "Пример" {
t.Errorf("SiteName = %q", preview.SiteName)
}
if want := server.URL + "/img/pic.png"; preview.ImageURL != want {
t.Errorf("ImageURL = %q, want %q", preview.ImageURL, want)
}
}
func TestFetchFallbackToTitleAndDescription(t *testing.T) {
const page = `<html><head>
<title>
Заголовок
в несколько строк
</title>
<meta name="description" content=" Описание
страницы ">
</head><body></body></html>`
server := serveHTML(t, "text/html", page)
preview, err := testFetcher(t, nil).Fetch(context.Background(), server.URL)
if err != nil {
t.Fatalf("Fetch() вернул ошибку: %v", err)
}
if preview.Title != "Заголовок в несколько строк" {
t.Errorf("Title = %q", preview.Title)
}
if preview.Description != "Описание страницы" {
t.Errorf("Description = %q", preview.Description)
}
}
func TestFetchTwitterFallback(t *testing.T) {
const page = `<html><head>
<meta name="twitter:title" content="Твиттер-заголовок">
<meta name="twitter:description" content="Твиттер-описание">
<meta name="twitter:image" content="img/tw.png">
</head></html>`
server := serveHTML(t, "text/html", page)
preview, err := testFetcher(t, nil).Fetch(context.Background(), server.URL+"/dir/page")
if err != nil {
t.Fatalf("Fetch() вернул ошибку: %v", err)
}
if preview.Title != "Твиттер-заголовок" {
t.Errorf("Title = %q", preview.Title)
}
if preview.Description != "Твиттер-описание" {
t.Errorf("Description = %q", preview.Description)
}
// Относительная картинка разрешается от каталога страницы.
if want := server.URL + "/dir/img/tw.png"; preview.ImageURL != want {
t.Errorf("ImageURL = %q, want %q", preview.ImageURL, want)
}
}
func TestFetchRejectsNonHTML(t *testing.T) {
cases := []struct {
name string
contentType string
}{
{"png", "image/png"},
{"pdf", "application/pdf"},
{"json", "application/json; charset=utf-8"},
{"пустой", ""},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// Пустое значение всё равно выставляем в карту заголовков:
// так сервер не подставляет Content-Type по содержимому.
w.Header()["Content-Type"] = []string{testCase.contentType}
_, _ = io.WriteString(w, `<html><head><title>нет</title></head></html>`)
}))
t.Cleanup(server.Close)
_, err := testFetcher(t, nil).Fetch(context.Background(), server.URL)
if !errors.Is(err, ErrContentType) {
t.Fatalf("err = %v, want ErrContentType", err)
}
})
}
}
func TestFetchRejectsTooLarge(t *testing.T) {
body := `<html><head><title>большая страница</title></head><body>` +
strings.Repeat("x", 4096) + `</body></html>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
// Content-Length задаём явно: по нему размер проверяется до чтения тела.
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
_, _ = io.WriteString(w, body)
}))
t.Cleanup(server.Close)
fetcher := testFetcher(t, func(options *Options) { options.MaxBytes = 64 })
_, err := fetcher.Fetch(context.Background(), server.URL)
if !errors.Is(err, ErrTooLarge) {
t.Fatalf("err = %v, want ErrTooLarge", err)
}
}
// TestFetchTruncatedBodyKeepsMetadata: без Content-Length тело читается до
// MaxBytes, и уже разобранные метаданные не теряются.
func TestFetchTruncatedBodyKeepsMetadata(t *testing.T) {
head := `<html><head><title>Короткий заголовок</title></head><body>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = io.WriteString(w, head)
w.(http.Flusher).Flush() // без Content-Length: ответ пойдёт chunked
_, _ = io.WriteString(w, strings.Repeat("x", 8192))
}))
t.Cleanup(server.Close)
fetcher := testFetcher(t, func(options *Options) { options.MaxBytes = 96 })
preview, err := fetcher.Fetch(context.Background(), server.URL)
if err != nil {
t.Fatalf("Fetch() вернул ошибку: %v", err)
}
if preview.Title != "Короткий заголовок" {
t.Errorf("Title = %q, want %q", preview.Title, "Короткий заголовок")
}
}
func TestFetchStopsAtHead(t *testing.T) {
const page = `<html><head></head><body><title>Из тела</title>
<meta property="og:title" content="Тоже из тела"></body></html>`
server := serveHTML(t, "text/html", page)
preview, err := testFetcher(t, nil).Fetch(context.Background(), server.URL)
if err != nil {
t.Fatalf("Fetch() вернул ошибку: %v", err)
}
if !preview.Empty() {
t.Errorf("превью = %+v, want пустое: тело страницы не разбираем", preview)
}
if preview.URL != server.URL+"/" {
t.Errorf("URL = %q, want %q", preview.URL, server.URL+"/")
}
}
// TestFetchBlocksInternalAddresses: запросы на внутренние адреса отбиваются до
// соединения, серверы поднимать не нужно.
func TestFetchBlocksInternalAddresses(t *testing.T) {
urls := []string{
"http://127.0.0.1:1/",
"http://127.255.255.254/",
"http://169.254.169.254/latest/meta-data/",
"http://10.1.2.3/",
"http://192.168.1.1/",
"http://172.16.0.1/",
"http://172.31.255.254/",
"http://0.0.0.0/",
"http://100.64.0.1/",
"http://[::1]:8080/",
"http://[::ffff:10.0.0.1]/",
"http://[fe80::1]/",
"http://[::]/",
}
fetcher := NewFetcher(Options{Timeout: time.Second})
start := time.Now()
for _, raw := range urls {
t.Run(raw, func(t *testing.T) {
_, err := fetcher.Fetch(context.Background(), raw)
if !errors.Is(err, ErrBlocked) {
t.Fatalf("Fetch(%q) err = %v, want ErrBlocked", raw, err)
}
})
}
// Проверка идёт до соединения: весь набор укладывается в доли секунды.
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Errorf("проверка адресов заняла %v — похоже, запросы всё-таки уходили в сеть", elapsed)
}
}
func TestFetchAllowPrivateDisabledBlocksLoopback(t *testing.T) {
server := serveHTML(t, "text/html", `<html><head><title>привет</title></head></html>`)
fetcher := NewFetcher(Options{Timeout: time.Second})
_, err := fetcher.Fetch(context.Background(), server.URL)
if !errors.Is(err, ErrBlocked) {
t.Fatalf("err = %v, want ErrBlocked", err)
}
}
func TestFetchRejectsUnsupportedURL(t *testing.T) {
fetcher := NewFetcher(Options{Timeout: time.Second})
for _, raw := range []string{"file:///etc/passwd", "javascript:alert(1)", "http://user:pass@example.com/"} {
if _, err := fetcher.Fetch(context.Background(), raw); !errors.Is(err, ErrUnsupported) {
t.Errorf("Fetch(%q) err = %v, want ErrUnsupported", raw, err)
}
}
}
func TestFetchRedirectToBlockedAddress(t *testing.T) {
// Петля в тестовом режиме разрешена (на ней стоят httptest-серверы),
// поэтому цель перехода — заведомо запрещённый частный адрес.
const blocked = "http://10.1.2.3:1/"
var mux http.ServeMux
mux.HandleFunc("/one", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/two", http.StatusFound)
})
mux.HandleFunc("/two", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, blocked, http.StatusFound)
})
server := httptest.NewServer(&mux)
t.Cleanup(server.Close)
_, err := testFetcher(t, nil).Fetch(context.Background(), server.URL+"/one")
if !errors.Is(err, ErrBlocked) {
t.Fatalf("err = %v, want ErrBlocked", err)
}
}
func TestFetchRedirectToUnsupportedScheme(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "file:///etc/passwd", http.StatusFound)
}))
t.Cleanup(server.Close)
_, err := testFetcher(t, nil).Fetch(context.Background(), server.URL)
if !errors.Is(err, ErrUnsupported) {
t.Fatalf("err = %v, want ErrUnsupported", err)
}
}
func TestFetchRedirectLimit(t *testing.T) {
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
http.Redirect(w, r, "/next", http.StatusFound)
}))
t.Cleanup(server.Close)
_, err := testFetcher(t, nil).Fetch(context.Background(), server.URL)
if !errors.Is(err, errTooManyRedirects) {
t.Fatalf("err = %v, want errTooManyRedirects", err)
}
if got := requests.Load(); got != maxRedirects+1 {
t.Errorf("запросов = %d, want %d (исходный + %d перехода)", got, maxRedirects+1, maxRedirects)
}
}
// TestFetchFinalURLIsBaseForRelativeLinks: относительная картинка считается от
// адреса после редиректов, а не от исходной ссылки.
func TestFetchFinalURLIsBaseForRelativeLinks(t *testing.T) {
var mux http.ServeMux
mux.HandleFunc("/start", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/dir/final", http.StatusFound)
})
mux.HandleFunc("/dir/final", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = io.WriteString(w, `<html><head><meta property="og:image" content="pic.png"></head></html>`)
})
server := httptest.NewServer(&mux)
t.Cleanup(server.Close)
preview, err := testFetcher(t, nil).Fetch(context.Background(), server.URL+"/start")
if err != nil {
t.Fatalf("Fetch() вернул ошибку: %v", err)
}
if want := server.URL + "/dir/pic.png"; preview.ImageURL != want {
t.Errorf("ImageURL = %q, want %q", preview.ImageURL, want)
}
}
func TestFetchTimeout(t *testing.T) {
const timeout = 250 * time.Millisecond
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(800 * time.Millisecond) // дольше таймаута загрузчика
w.Header().Set("Content-Type", "text/html")
_, _ = io.WriteString(w, `<html><head><title>поздно</title></head></html>`)
}))
t.Cleanup(server.Close)
fetcher := testFetcher(t, func(options *Options) { options.Timeout = timeout })
start := time.Now()
_, err := fetcher.Fetch(context.Background(), server.URL)
elapsed := time.Since(start)
if err == nil {
t.Fatal("Fetch() вернул nil, want ошибку таймаута")
}
if !errors.Is(err, context.DeadlineExceeded) && !os.IsTimeout(err) {
t.Errorf("err = %v, want таймаут", err)
}
if elapsed > 2*timeout {
t.Errorf("Fetch() занял %v, want не больше %v", elapsed, 2*timeout)
}
}
func TestFetchDropsBlockedImage(t *testing.T) {
cases := []struct {
name string
page string
}{
{"частный адрес", `<meta property="og:image" content="http://10.0.0.1/x.png">`},
{"метаданные облака", `<meta property="og:image" content="http://169.254.169.254/x.png">`},
{"javascript", `<meta property="og:image" content="javascript:alert(1)">`},
{"data", `<meta property="og:image" content="data:image/png;base64,AAAA">`},
{"localhost", `<meta property="og:image" content="http://localhost/x.png">`},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
page := `<html><head><meta property="og:title" content="Заголовок">` + testCase.page + `</head></html>`
server := serveHTML(t, "text/html", page)
preview, err := testFetcher(t, nil).Fetch(context.Background(), server.URL)
if err != nil {
t.Fatalf("Fetch() вернул ошибку: %v", err)
}
if preview.ImageURL != "" {
t.Errorf("ImageURL = %q, want пусто", preview.ImageURL)
}
if preview.Title != "Заголовок" {
t.Errorf("Title = %q, want %q", preview.Title, "Заголовок")
}
})
}
}
func TestFetchSanitizesText(t *testing.T) {
const page = "<html><head>" +
"<meta property=\"og:title\" content=\"Заголовок\u0001 с \u0007управляющими\n\n\tсимволами и пробелами\">" +
"<meta property=\"og:description\" content=\" описание\nс переводом \">" +
"<meta property=\"og:site_name\" content=\" сайт \">" +
"</head></html>"
server := serveHTML(t, "text/html", page)
preview, err := testFetcher(t, nil).Fetch(context.Background(), server.URL)
if err != nil {
t.Fatalf("Fetch() вернул ошибку: %v", err)
}
if want := "Заголовок с управляющими символами и пробелами"; preview.Title != want {
t.Errorf("Title = %q, want %q", preview.Title, want)
}
if want := "описание с переводом"; preview.Description != want {
t.Errorf("Description = %q, want %q", preview.Description, want)
}
if want := "сайт"; preview.SiteName != want {
t.Errorf("SiteName = %q, want %q", preview.SiteName, want)
}
}
func TestFetchTruncatesLongText(t *testing.T) {
page := `<html><head>` +
`<meta property="og:title" content="` + strings.Repeat("я", 500) + `">` +
`<meta property="og:description" content="` + strings.Repeat("d", 900) + `">` +
`<meta property="og:site_name" content="` + strings.Repeat("s", 300) + `">` +
`</head></html>`
server := serveHTML(t, "text/html", page)
preview, err := testFetcher(t, nil).Fetch(context.Background(), server.URL)
if err != nil {
t.Fatalf("Fetch() вернул ошибку: %v", err)
}
if got := utf8.RuneCountInString(preview.Title); got != titleLimit {
t.Errorf("длина Title = %d рун, want %d", got, titleLimit)
}
if got := utf8.RuneCountInString(preview.Description); got != descriptionLimit {
t.Errorf("длина Description = %d рун, want %d", got, descriptionLimit)
}
if got := utf8.RuneCountInString(preview.SiteName); got != siteNameLimit {
t.Errorf("длина SiteName = %d рун, want %d", got, siteNameLimit)
}
}
func TestFetchPageWithoutMetadata(t *testing.T) {
server := serveHTML(t, "text/html", `<html><head><meta charset="utf-8"></head><body>текст</body></html>`)
preview, err := testFetcher(t, nil).Fetch(context.Background(), server.URL+"/page")
if err != nil {
t.Fatalf("Fetch() вернул ошибку: %v", err)
}
if !preview.Empty() {
t.Errorf("превью = %+v, want пустое", preview)
}
if want := server.URL + "/page"; preview.URL != want {
t.Errorf("URL = %q, want %q", preview.URL, want)
}
}
// TestFetchHidesQueryInErrorsAndLogs: полный URL с query не попадает ни в
// ошибку, ни в лог — только хост и код причины (приватность).
func TestFetchHidesQueryInErrorsAndLogs(t *testing.T) {
cases := []struct {
name string
rawURL string
}{
{"запрещённый литерал", "http://127.0.0.1:1/page?token=secret"},
{"ошибка соединения", "http://rebind.invalid/page?token=secret"},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
var logged bytes.Buffer
logger := slog.New(slog.NewTextHandler(&logged, &slog.HandlerOptions{Level: slog.LevelDebug}))
fetcher := NewFetcher(Options{
Timeout: time.Second,
Logger: logger,
Resolver: fakeResolver(t, netip.MustParseAddr("10.1.2.3")),
})
_, err := fetcher.Fetch(context.Background(), testCase.rawURL)
if !errors.Is(err, ErrBlocked) {
t.Fatalf("err = %v, want ErrBlocked", err)
}
if strings.Contains(err.Error(), "secret") {
t.Errorf("в ошибке остался query: %v", err)
}
if strings.Contains(logged.String(), "secret") {
t.Errorf("в логе остался query: %s", logged.String())
}
if !strings.Contains(logged.String(), "host=") || !strings.Contains(logged.String(), "blocked") {
t.Errorf("лог не содержит хост и код причины: %s", logged.String())
}
})
}
}
// TestFetchBlocksDNSRebinding: имя резолвится в частный адрес, но соединение
// не открывается — адрес проверяется в момент подключения. Локальный
// DNS-сервер отдаёт 10.1.2.3, наружу тест не ходит.
func TestFetchBlocksDNSRebinding(t *testing.T) {
resolver := fakeResolver(t, netip.MustParseAddr("10.1.2.3"))
fetcher := NewFetcher(Options{Timeout: time.Second, Resolver: resolver})
start := time.Now()
_, err := fetcher.Fetch(context.Background(), "http://rebind.invalid/page")
if !errors.Is(err, ErrBlocked) {
t.Fatalf("err = %v, want ErrBlocked", err)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("проверка заняла %v — похоже, соединение всё-таки открывалось", elapsed)
}
}
// fakeResolver поднимает минимальный DNS-сервер на петле и отвечает указанным
// адресом на любой A-запрос. Нужен, чтобы проверить защиту от DNS rebinding
// без выхода во внешнюю сеть.
func fakeResolver(t *testing.T, answer netip.Addr) *net.Resolver {
t.Helper()
var listenConfig net.ListenConfig
conn, err := listenConfig.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("не удалось занять UDP-порт: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
go func() {
buffer := make([]byte, 1500)
for {
n, addr, err := conn.ReadFrom(buffer)
if err != nil {
return
}
if response := dnsAnswer(buffer[:n], answer); response != nil {
_, _ = conn.WriteTo(response, addr)
}
}
}()
return &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
protocol := "udp"
if strings.HasPrefix(network, "tcp") {
protocol = "tcp"
}
var dialer net.Dialer
return dialer.DialContext(ctx, protocol, conn.LocalAddr().String())
},
}
}
// dnsAnswer собирает ответ на запрос: A-запись с нужным адресом, для остальных
// типов — пустой список ответов (NODATA). Ошибки разбора означают, что пакет
// не наш — такой запрос просто игнорируется.
func dnsAnswer(query []byte, answer netip.Addr) []byte {
var parser dnsmessage.Parser
header, err := parser.Start(query)
if err != nil {
return nil
}
question, err := parser.Question()
if err != nil {
return nil
}
builder := dnsmessage.NewBuilder(nil, dnsmessage.Header{
ID: header.ID,
Response: true,
Authoritative: true,
RecursionAvailable: true,
})
builder.EnableCompression()
if err := builder.StartQuestions(); err != nil {
return nil
}
if err := builder.Question(question); err != nil {
return nil
}
if err := builder.StartAnswers(); err != nil {
return nil
}
if question.Type == dnsmessage.TypeA && answer.Is4() {
err := builder.AResource(
dnsmessage.ResourceHeader{Name: question.Name, Class: dnsmessage.ClassINET, TTL: 60},
dnsmessage.AResource{A: answer.As4()})
if err != nil {
return nil
}
}
message, err := builder.Finish()
if err != nil {
return nil
}
return message
}