a9b1073877
Сервер сам загружает заголовок, описание и картинку страницы по ссылке из сообщения и отдаёт клиенту готовую карточку. Безопасность (главное здесь): - только 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 и миграция.
242 lines
8.9 KiB
Go
242 lines
8.9 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"glchat/internal/store"
|
|
"glchat/internal/unfurl"
|
|
)
|
|
|
|
// withUnfurl подменяет загрузчик превью на тестовый: httptest-серверы живут
|
|
// на петле, поэтому AllowPrivate разрешён только здесь.
|
|
func withUnfurl(t *testing.T, srv *Server, st *store.Store) {
|
|
t.Helper()
|
|
srv.unfurl = &linkPreviewer{
|
|
enabled: true,
|
|
fetcher: unfurl.NewFetcher(unfurl.Options{AllowPrivate: true}),
|
|
store: st,
|
|
now: func() time.Time { return time.Now().UTC() },
|
|
}
|
|
}
|
|
|
|
// htmlServer отдаёт страницу с метаданными Open Graph.
|
|
func htmlServer(t *testing.T, body string) *httptest.Server {
|
|
t.Helper()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write([]byte(body))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
return server
|
|
}
|
|
|
|
func TestLinkPreviewReturnsCardAndCaches(t *testing.T) {
|
|
srv, st := newPushTestServer(t, true)
|
|
withUnfurl(t, srv, st)
|
|
cookie := registerAndLogin(t, srv, "unfurl_user", "unfurl@example.com")
|
|
|
|
page := htmlServer(t, `<!doctype html><html><head>
|
|
<meta property="og:title" content="Заголовок страницы">
|
|
<meta property="og:description" content="Краткое описание">
|
|
<meta property="og:site_name" content="Example">
|
|
<meta property="og:image" content="/cover.png">
|
|
</head><body>текст</body></html>`)
|
|
|
|
rec := doJSON(t, srv, http.MethodGet,
|
|
"/api/v1/link-previews?url="+urlQueryEscape(page.URL+"/page?utm=1"), "", cookie)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("превью = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
payload := decodeResponse[struct {
|
|
Status string `json:"status"`
|
|
Preview struct {
|
|
URL string `json:"url"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
SiteName string `json:"site_name"`
|
|
ImageURL string `json:"image_url"`
|
|
} `json:"preview"`
|
|
}](t, rec)
|
|
if payload.Status != store.LinkPreviewStatusOK {
|
|
t.Fatalf("статус = %q, body = %s", payload.Status, rec.Body.String())
|
|
}
|
|
if payload.Preview.Title != "Заголовок страницы" || payload.Preview.Description != "Краткое описание" {
|
|
t.Fatalf("неожиданная карточка: %+v", payload.Preview)
|
|
}
|
|
if payload.Preview.SiteName != "Example" {
|
|
t.Fatalf("site_name = %q", payload.Preview.SiteName)
|
|
}
|
|
if !strings.HasPrefix(payload.Preview.ImageURL, page.URL) {
|
|
t.Fatalf("относительная картинка не развёрнута: %q", payload.Preview.ImageURL)
|
|
}
|
|
|
|
// Второй запрос обслуживается кэшем: страница уже недоступна.
|
|
page.Close()
|
|
rec = doJSON(t, srv, http.MethodGet,
|
|
"/api/v1/link-previews?url="+urlQueryEscape(page.URL+"/page?utm=1"), "", cookie)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("повторный запрос = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
cached := decodeResponse[struct {
|
|
Status string `json:"status"`
|
|
Preview struct {
|
|
Title string `json:"title"`
|
|
} `json:"preview"`
|
|
}](t, rec)
|
|
if cached.Status != store.LinkPreviewStatusOK || cached.Preview.Title != "Заголовок страницы" {
|
|
t.Fatalf("кэш не отдал превью: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestLinkPreviewEmptyPage(t *testing.T) {
|
|
srv, st := newPushTestServer(t, true)
|
|
withUnfurl(t, srv, st)
|
|
cookie := registerAndLogin(t, srv, "unfurl_empty", "unfurl-empty@example.com")
|
|
|
|
page := htmlServer(t, `<!doctype html><html><head><title></title></head><body>нет метаданных</body></html>`)
|
|
rec := doJSON(t, srv, http.MethodGet,
|
|
"/api/v1/link-previews?url="+urlQueryEscape(page.URL), "", cookie)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("ответ = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
payload := decodeResponse[struct {
|
|
Status string `json:"status"`
|
|
Preview any `json:"preview"`
|
|
}](t, rec)
|
|
// <title></title> пустой: карточки нет.
|
|
if payload.Status != store.LinkPreviewStatusEmpty || payload.Preview != nil {
|
|
t.Fatalf("ожидался empty без карточки: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestLinkPreviewRejectsInternalAndUnsupported(t *testing.T) {
|
|
srv, st := newPushTestServer(t, true)
|
|
withUnfurl(t, srv, st)
|
|
cookie := registerAndLogin(t, srv, "unfurl_bad", "unfurl-bad@example.com")
|
|
|
|
// Проверяем боевую конфигурацию: AllowPrivate в тестовом загрузчике
|
|
// разрешает петлю, поэтому здесь собираем загрузчик как в продакшене.
|
|
srv.unfurl.fetcher = unfurl.NewFetcher(unfurl.Options{})
|
|
|
|
cases := map[string]string{
|
|
"метаданные облака": "https://169.254.169.254/latest/meta-data/",
|
|
"частная сеть": "https://10.1.2.3/secret",
|
|
"петля": "https://127.0.0.1:8080/api/v1/meta",
|
|
"file": "file:///etc/passwd",
|
|
"javascript": "javascript:alert(1)",
|
|
"data": "data:text/html,<h1>x</h1>",
|
|
}
|
|
for name, target := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
rec := doJSON(t, srv, http.MethodGet,
|
|
"/api/v1/link-previews?url="+urlQueryEscape(target), "", cookie)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("ответ = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
payload := decodeResponse[struct {
|
|
Status string `json:"status"`
|
|
Preview any `json:"preview"`
|
|
}](t, rec)
|
|
if payload.Status != store.LinkPreviewStatusBlocked {
|
|
t.Fatalf("статус = %q, ожидался blocked (%s)", payload.Status, rec.Body.String())
|
|
}
|
|
if payload.Preview != nil {
|
|
t.Fatalf("карточка не должна отдаваться: %s", rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLinkPreviewNonHTMLIsError(t *testing.T) {
|
|
srv, st := newPushTestServer(t, true)
|
|
withUnfurl(t, srv, st)
|
|
cookie := registerAndLogin(t, srv, "unfurl_json", "unfurl-json@example.com")
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"title":"я не страница"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
rec := doJSON(t, srv, http.MethodGet,
|
|
"/api/v1/link-previews?url="+urlQueryEscape(server.URL), "", cookie)
|
|
payload := decodeResponse[struct {
|
|
Status string `json:"status"`
|
|
}](t, rec)
|
|
if payload.Status != store.LinkPreviewStatusError {
|
|
t.Fatalf("статус = %q, ожидался error (%s)", payload.Status, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestLinkPreviewDisabled(t *testing.T) {
|
|
srv, _ := newPushTestServer(t, true)
|
|
srv.unfurl.enabled = false
|
|
cookie := registerAndLogin(t, srv, "unfurl_off", "unfurl-off@example.com")
|
|
|
|
rec := doJSON(t, srv, http.MethodGet,
|
|
"/api/v1/link-previews?url=https%3A%2F%2Fexample.com%2F", "", cookie)
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("ответ = %d, ожидался 503", rec.Code)
|
|
}
|
|
if code := errorCodeOf(t, rec); code != "unfurl.disabled" {
|
|
t.Fatalf("код ошибки = %q", code)
|
|
}
|
|
}
|
|
|
|
func TestLinkPreviewRequiresSession(t *testing.T) {
|
|
srv, _ := newPushTestServer(t, true)
|
|
rec := doJSON(t, srv, http.MethodGet,
|
|
"/api/v1/link-previews?url=https%3A%2F%2Fexample.com%2F", "")
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("ответ = %d, ожидался 401", rec.Code)
|
|
}
|
|
}
|
|
|
|
// Кэш не расходует лимит, а новые загрузки — расходуют: иначе один клиент
|
|
// может гонять чужие страницы пачками.
|
|
func TestLinkPreviewRateLimit(t *testing.T) {
|
|
srv, st := newPushTestServer(t, true)
|
|
withUnfurl(t, srv, st)
|
|
cookie := registerAndLogin(t, srv, "unfurl_flood", "unfurl-flood@example.com")
|
|
|
|
page := htmlServer(t, `<html><head><meta property="og:title" content="t"></head></html>`)
|
|
limited := false
|
|
for i := 0; i < 40; i++ {
|
|
target := page.URL + "/page-" + itoa(i)
|
|
rec := doJSON(t, srv, http.MethodGet,
|
|
"/api/v1/link-previews?url="+urlQueryEscape(target), "", cookie)
|
|
if rec.Code == http.StatusTooManyRequests {
|
|
limited = true
|
|
break
|
|
}
|
|
}
|
|
if !limited {
|
|
t.Fatal("лимит новых загрузок превью не сработал")
|
|
}
|
|
}
|
|
|
|
func TestMetaExposesUnfurlFlag(t *testing.T) {
|
|
srv, _ := newPushTestServer(t, true)
|
|
rec := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "")
|
|
payload := decodeResponse[struct {
|
|
Features struct {
|
|
UnfurlEnabled bool `json:"unfurl_enabled"`
|
|
} `json:"features"`
|
|
}](t, rec)
|
|
if !payload.Features.UnfurlEnabled {
|
|
t.Fatal("meta не сообщает о включённом unfurl")
|
|
}
|
|
}
|
|
|
|
// urlQueryEscape кодирует URL для query-параметра.
|
|
func urlQueryEscape(value string) string { return url.QueryEscape(value) }
|
|
|
|
// itoa нужен для уникальных адресов страниц в тесте лимита.
|
|
func itoa(value int) string { return strconv.Itoa(value) }
|