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,

x

", "", 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 = ` тело` 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 = ` Заголовок в несколько строк ` 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 = ` ` 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, `нет`) })) 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 := `большая страница` + strings.Repeat("x", 4096) + `` 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 := `Короткий заголовок` 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 = `Из тела ` 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", `привет`) 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, ``) }) 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, `поздно`) })) 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 }{ {"частный адрес", ``}, {"метаданные облака", ``}, {"javascript", ``}, {"data", ``}, {"localhost", ``}, } for _, testCase := range cases { t.Run(testCase.name, func(t *testing.T) { page := `` + testCase.page + `` 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 = "" + "" + "" + "" + "" 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 := `` + `` + `` + `` + `` 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", `текст`) 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 }