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 и миграция.
357 lines
12 KiB
Go
357 lines
12 KiB
Go
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")
|
||
}
|
||
}
|