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

357 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"fmt"
"log/slog"
"net/http"
"path/filepath"
"testing"
"time"
"glchat/internal/auth"
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/gateway"
"glchat/internal/httpx"
"glchat/internal/permissions"
"glchat/internal/source"
"glchat/internal/store"
)
// newPushTestServer поднимает тестовый сервер с настроенным VAPID-ключом:
// newTestServer из server_test.go о push ничего не знает, а ручки обязаны
// работать и с ключом, и без него.
func newPushTestServer(t *testing.T, withKeys bool) (*Server, *store.Store) {
t.Helper()
ctx := context.Background()
db, err := database.Open(ctx, database.Options{
Path: filepath.Join(t.TempDir(), "glchat.db"),
ReadPool: 2,
Migrate: true,
})
if err != nil {
t.Fatalf("open test database: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("close test database: %v", err)
}
})
cfg := config.Config{
DataDir: t.TempDir(),
Domain: "gl.mhspx.su",
WebRoot: filepath.Join("testdata", "web"),
FilesDomain: "files.gl.mhspx.su",
InstanceName: "glchat",
ListenAddr: "127.0.0.1:0",
Version: "v0.1.0-test",
Commit: "deadbee",
BuildDate: "2026-09-19T00:00:00Z",
MaxUploadSize: 26214400,
TLSEnabled: true,
SessionPepper: "test-pepper",
MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
Argon2MemoryKiB: 1024,
Argon2Iterations: 1,
Argon2Parallelism: 1,
LogLevel: "error",
LogFormat: "json",
UnfurlEnabled: true,
UnfurlTimeout: 5 * time.Second,
}
if withKeys {
cfg.VAPIDPrivateKey = testVAPIDPrivateKey(t)
cfg.VAPIDSubject = "mailto:admin@gl.mhspx.su"
}
logger := slog.New(slog.DiscardHandler)
st := store.New(db)
authService, err := auth.New(context.Background(), cfg, st, logger)
if err != nil {
t.Fatalf("initialize authentication: %v", err)
}
calculator := permissions.NewCalculator(source.New(st))
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st, calculator), logger, cfg.AllowedOrigins())
return New(cfg, db, logger, Deps{
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
}), st
}
func testVAPIDPrivateKey(t *testing.T) string {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatalf("marshal PKCS#8: %v", err)
}
return base64.StdEncoding.EncodeToString(der)
}
// testPushKeys возвращает корректные ключи подписки (65-байтовая точка P-256
// и 16 байт auth) в base64url — как их отдаёт PushSubscription.toJSON().
func testPushKeys(t *testing.T) (string, string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate subscription key: %v", err)
}
point, err := key.PublicKey.Bytes()
if err != nil {
t.Fatalf("public key bytes: %v", err)
}
auth := make([]byte, 16)
if _, err := rand.Read(auth); err != nil {
t.Fatalf("auth: %v", err)
}
return base64.RawURLEncoding.EncodeToString(point), base64.RawURLEncoding.EncodeToString(auth)
}
// testAuthKey — корректный auth-ключ подписки (16 байт).
func testAuthKey(t *testing.T) string {
t.Helper()
_, auth := testPushKeys(t)
return auth
}
func pushSubscribeBody(t *testing.T, endpoint string) string {
t.Helper()
p256dh, auth := testPushKeys(t)
return fmt.Sprintf(`{"endpoint":%q,"keys":{"p256dh":%q,"auth":%q}}`, endpoint, p256dh, auth)
}
func TestPushConfigDisabledWithoutKeys(t *testing.T) {
srv, _ := newPushTestServer(t, false)
cookie := registerAndLogin(t, srv, "push_off", "push-off@example.com")
rec := doJSON(t, srv, http.MethodGet, "/api/v1/push/config", "", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("GET /push/config = %d, body = %s", rec.Code, rec.Body.String())
}
payload := decodeResponse[struct {
Enabled bool `json:"enabled"`
PublicKey string `json:"public_key"`
}](t, rec)
if payload.Enabled || payload.PublicKey != "" {
t.Fatalf("без ключей push должен быть выключен: %+v", payload)
}
// Подписка на инстансе без ключей — честная ошибка, а не молчаливый успех.
rec = doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
pushSubscribeBody(t, "https://fcm.googleapis.com/fcm/send/disabled"), cookie)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("подписка без ключей = %d, ожидалось 503", rec.Code)
}
if code := errorCodeOf(t, rec); code != "push.disabled" {
t.Fatalf("код ошибки = %q, ожидался push.disabled", code)
}
}
func TestPushSubscribeFlow(t *testing.T) {
srv, st := newPushTestServer(t, true)
cookie := registerAndLogin(t, srv, "push_on", "push-on@example.com")
user, err := srv.auth.UserByEmail(t.Context(), "push-on@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
config := decodeResponse[struct {
Enabled bool `json:"enabled"`
PublicKey string `json:"public_key"`
}](t, doJSON(t, srv, http.MethodGet, "/api/v1/push/config", "", cookie))
if !config.Enabled || config.PublicKey == "" {
t.Fatalf("push должен быть включён: %+v", config)
}
decoded, err := base64.RawURLEncoding.DecodeString(config.PublicKey)
if err != nil || len(decoded) != 65 {
t.Fatalf("публичный ключ не годится для applicationServerKey: %v", err)
}
endpoint := "https://fcm.googleapis.com/fcm/send/device-one"
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
pushSubscribeBody(t, endpoint), cookie)
if rec.Code != http.StatusOK {
t.Fatalf("подписка = %d, body = %s", rec.Code, rec.Body.String())
}
subscriptions, err := st.ListPushSubscriptions(t.Context(), user.ID)
if err != nil || len(subscriptions) != 1 {
t.Fatalf("подписок в базе %d (%v), ожидалась 1", len(subscriptions), err)
}
if subscriptions[0].Endpoint != endpoint {
t.Fatalf("эндпоинт = %q", subscriptions[0].Endpoint)
}
// Список для интерфейса отдаёт устройства и лимит.
list := decodeResponse[struct {
Subscriptions []struct {
ID string `json:"id"`
Endpoint string `json:"endpoint"`
} `json:"subscriptions"`
Limit int `json:"limit"`
}](t, doJSON(t, srv, http.MethodGet, "/api/v1/push/subscriptions", "", cookie))
if len(list.Subscriptions) != 1 || list.Limit != maxPushSubscriptionsPerUser {
t.Fatalf("неожиданный список подписок: %+v", list)
}
// Отписка устройства.
rec = doJSON(t, srv, http.MethodDelete,
"/api/v1/push/subscriptions?endpoint="+endpoint, "", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("отписка = %d, body = %s", rec.Code, rec.Body.String())
}
if count, err := st.CountPushSubscriptions(t.Context(), user.ID); err != nil || count != 0 {
t.Fatalf("после отписки подписок %d (%v)", count, err)
}
}
func TestPushSubscribeValidation(t *testing.T) {
srv, _ := newPushTestServer(t, true)
cookie := registerAndLogin(t, srv, "push_bad", "push-bad@example.com")
cases := []struct {
name string
body string
code string
}{
{
name: "http вместо https",
body: pushSubscribeBody(t, "http://fcm.googleapis.com/fcm/send/x"),
code: "push.invalid_endpoint",
},
{
name: "внутренний адрес",
body: pushSubscribeBody(t, "https://10.0.0.5/push"),
code: "push.invalid_endpoint",
},
{
name: "метаданные облака",
body: pushSubscribeBody(t, "https://169.254.169.254/latest/meta-data"),
code: "push.invalid_endpoint",
},
{
name: "loopback",
body: pushSubscribeBody(t, "https://127.0.0.1:8443/push"),
code: "push.invalid_endpoint",
},
{
name: "p256dh не точка кривой",
body: fmt.Sprintf(`{"endpoint":"https://fcm.googleapis.com/fcm/send/x","keys":{"p256dh":%q,"auth":%q}}`,
base64.RawURLEncoding.EncodeToString(make([]byte, 65)), testAuthKey(t)),
code: "push.invalid_keys",
},
{
name: "битый ключ шифрования",
body: `{"endpoint":"https://fcm.googleapis.com/fcm/send/x","keys":{"p256dh":"AAAA","auth":"AAAA"}}`,
code: "push.invalid_keys",
},
{
name: "пустой ключ",
body: `{"endpoint":"https://fcm.googleapis.com/fcm/send/x","keys":{"p256dh":"","auth":""}}`,
code: "push.invalid_keys",
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions", testCase.body, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("код ответа = %d, ожидался 422 (body = %s)", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != testCase.code {
t.Fatalf("код ошибки = %q, ожидался %q", code, testCase.code)
}
})
}
}
func TestPushSubscribeLimit(t *testing.T) {
srv, _ := newPushTestServer(t, true)
cookie := registerAndLogin(t, srv, "push_limit", "push-limit@example.com")
// Лимит частоты проверяется отдельно: здесь важно дойти до предела
// устройств на пользователя.
srv.pushLimiter = httpx.NewRateLimiterWindow(100, time.Minute, 100)
for i := 0; i < maxPushSubscriptionsPerUser; i++ {
endpoint := fmt.Sprintf("https://fcm.googleapis.com/fcm/send/device-%d", i)
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
pushSubscribeBody(t, endpoint), cookie)
if rec.Code != http.StatusOK {
t.Fatalf("подписка %d = %d, body = %s", i, rec.Code, rec.Body.String())
}
}
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
pushSubscribeBody(t, "https://fcm.googleapis.com/fcm/send/device-over-limit"), cookie)
if rec.Code != http.StatusConflict {
t.Fatalf("подписка сверх лимита = %d, ожидалось 409 (body = %s)", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != "push.too_many_subscriptions" {
t.Fatalf("код ошибки = %q", code)
}
}
// Подписки ограничены и по частоте: перебор устройств не должен превращаться
// в поток запросов (AGENT.md 8.6).
func TestPushSubscribeRateLimited(t *testing.T) {
srv, _ := newPushTestServer(t, true)
cookie := registerAndLogin(t, srv, "push_flood", "push-flood@example.com")
var limited bool
for i := 0; i < 40; i++ {
endpoint := fmt.Sprintf("https://fcm.googleapis.com/fcm/send/flood-%d", i)
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
pushSubscribeBody(t, endpoint), cookie)
if rec.Code == http.StatusTooManyRequests {
limited = true
break
}
}
if !limited {
t.Fatal("лимит частоты подписок не сработал")
}
}
func TestPushRoutesRequireSession(t *testing.T) {
srv, _ := newPushTestServer(t, true)
body := pushSubscribeBody(t, "https://fcm.googleapis.com/fcm/send/no-session")
for _, request := range []struct {
method string
path string
body string
}{
{http.MethodGet, "/api/v1/push/config", ""},
{http.MethodGet, "/api/v1/push/subscriptions", ""},
{http.MethodPost, "/api/v1/push/subscriptions", body},
{http.MethodDelete, "/api/v1/push/subscriptions", ""},
} {
rec := doJSON(t, srv, request.method, request.path, request.body)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s %s без сессии = %d, ожидалось 401", request.method, request.path, rec.Code)
}
}
}
// Публичный ключ в /meta виден до входа: клиент решает, показывать ли раздел.
func TestMetaExposesWebPushFlag(t *testing.T) {
srv, _ := newPushTestServer(t, true)
rec := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET /meta = %d", rec.Code)
}
payload := decodeResponse[struct {
Features struct {
WebPushEnabled bool `json:"web_push_enabled"`
} `json:"features"`
}](t, rec)
if !payload.Features.WebPushEnabled {
t.Fatal("meta не сообщает о включённом Web Push")
}
}