// Package unfurl загружает серверные превью ссылок (unfurl, фаза 7 AGENT.md): // заголовок, описание, имя сайта и картинку из страницы. // // Пакет намеренно недоверчив к внешнему миру: // - только схемы 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 загружает страницу и разбирает метаданные её . Тело читается не // целиком: разбор прекращается на или по достижении 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)) }