feat(push): Web Push — VAPID, подписки устройств и отправка (AGENT.md 7.16)
Уведомления в браузере и на телефоне: сервер сам решает, кому их слать, и подписывает запрос VAPID-ключом, поэтому уведомление приходит, даже когда клиент закрыт. Сервер: - миграция 00021: `push_subscriptions` (эндпоинт уникален, ключи, счётчик неудач, время последней доставки); - `internal/push` — VAPID-ключи (приватный PKCS#8 из конфига, публичный выводится из него), правила уведомлений повторяют `web/src/lib/desktopNotifications.ts` (упоминания и личные беседы, без своих и системных сообщений), очередь доставки, TTL 12 часов, Topic по комнате; - 404/410 от push-сервиса удаляют подписку сразу, 5 неудач подряд — тоже, иначе копились бы мёртвые эндпоинты; retention чистит «молчащие» подписки; - `internal/httpx/safeurl.go` — общий запрет внутренних адресов с проверкой адреса в момент подключения (DNS rebinding): эндпоинт подписки приходит от клиента, и без проверки сервер сам себе организует SSRF; - `internal/gateway/presence.go` — `IsUserOnline`: если получатель в клиенте, уведомление покажет клиент, дублировать на телефон не нужно; - ручки `GET /push/config`, `GET|POST|DELETE /push/subscriptions`, лимиты 10 подписок и 20 уведомлений в минуту; ключ p256dh проверяется как настоящая точка P-256; - установщик генерирует `VAPID_PRIVATE_KEY` (openssl, PKCS#8 DER в base64) и `VAPID_SUBJECT`, ключ переиспользуется при переустановке; для ручной установки есть `glchat vapid-keys`; `features.web_push_enabled` виден в `/meta`. Тесты: правила и VAPID, доставка с поддельным push-эндпоинтом (шифрование, заголовки VAPID/TTL/Topic, очистка мёртвых подписок), ручки и лимиты, отправка при личном сообщении и упоминании, пропуск онлайн-получателей, миграция на чистой БД.
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIsBlockedIP покрывает адреса, по которым сервер не должен ходить сам
|
||||
// (AGENT.md 9.2, 9.7): внутренние сети, метаданные облака, multicast.
|
||||
func TestIsBlockedIP(t *testing.T) {
|
||||
blocked := []string{
|
||||
"127.0.0.1", "127.9.9.9", "::1",
|
||||
"10.0.0.1", "172.16.0.1", "172.31.255.255", "192.168.1.1",
|
||||
"169.254.169.254", "fe80::1",
|
||||
"0.0.0.0", "::", "0.1.2.3",
|
||||
"224.0.0.1", "ff02::1",
|
||||
"100.64.0.1", "100.127.255.255",
|
||||
"192.0.0.1",
|
||||
"fc00::1", "fd12:3456::1",
|
||||
"::ffff:10.0.0.1", "::ffff:127.0.0.1",
|
||||
"2001:db8::1",
|
||||
}
|
||||
for _, raw := range blocked {
|
||||
ip := net.ParseIP(raw)
|
||||
if ip == nil {
|
||||
t.Fatalf("не разобран адрес %q", raw)
|
||||
}
|
||||
if !IsBlockedIP(ip) {
|
||||
t.Errorf("IsBlockedIP(%s) = false, ожидалось true", raw)
|
||||
}
|
||||
}
|
||||
|
||||
allowed := []string{"8.8.8.8", "1.1.1.1", "138.16.226.159", "2606:4700::1111", "100.63.255.255"}
|
||||
for _, raw := range allowed {
|
||||
ip := net.ParseIP(raw)
|
||||
if ip == nil {
|
||||
t.Fatalf("не разобран адрес %q", raw)
|
||||
}
|
||||
if IsBlockedIP(ip) {
|
||||
t.Errorf("IsBlockedIP(%s) = true, ожидалось false", raw)
|
||||
}
|
||||
}
|
||||
|
||||
if !IsBlockedIP(nil) {
|
||||
t.Error("IsBlockedIP(nil) = false, ожидалось true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePublicURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
allowHTTP bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "https", raw: "https://example.com/page?a=1"},
|
||||
{name: "http запрещён", raw: "http://example.com/", wantErr: true},
|
||||
{name: "http разрешён флагом", raw: "http://example.com/", allowHTTP: true},
|
||||
{name: "file", raw: "file:///etc/passwd", wantErr: true},
|
||||
{name: "gopher", raw: "gopher://example.com/", wantErr: true},
|
||||
{name: "javascript", raw: "javascript:alert(1)", wantErr: true},
|
||||
{name: "userinfo", raw: "https://user:pass@example.com/", wantErr: true},
|
||||
{name: "пусто", raw: " ", wantErr: true},
|
||||
{name: "loopback", raw: "https://127.0.0.1/x", wantErr: true},
|
||||
{name: "метаданные", raw: "https://169.254.169.254/latest/meta-data/", wantErr: true},
|
||||
{name: "приватная сеть", raw: "https://10.1.2.3/", wantErr: true},
|
||||
{name: "ipv6 loopback", raw: "https://[::1]/", wantErr: true},
|
||||
{name: "слишком длинный", raw: "https://example.com/" + strings.Repeat("a", 2100), wantErr: true},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
parsed, err := ValidatePublicURL(testCase.raw, testCase.allowHTTP)
|
||||
if testCase.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("ValidatePublicURL(%q) = %v, ожидалась ошибка", testCase.raw, parsed)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatePublicURL(%q) вернул ошибку: %v", testCase.raw, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeDialControl проверяет, что проверка идёт по уже разрешённому адресу:
|
||||
// именно так закрывается DNS rebinding.
|
||||
func TestSafeDialControl(t *testing.T) {
|
||||
blocked := []string{"127.0.0.1:443", "[::1]:443", "169.254.169.254:80", "10.0.0.5:8080"}
|
||||
for _, address := range blocked {
|
||||
if err := SafeDialControl("tcp", address, nil); err == nil {
|
||||
t.Errorf("SafeDialControl пропустил %s", address)
|
||||
}
|
||||
}
|
||||
if err := SafeDialControl("tcp", "93.184.216.34:443", nil); err != nil {
|
||||
t.Fatalf("SafeDialControl отклонил публичный адрес: %v", err)
|
||||
}
|
||||
// Имя вместо адреса означает, что резолв не произошёл: доверять нельзя.
|
||||
if err := SafeDialControl("tcp", "example.com:443", nil); err == nil {
|
||||
t.Fatal("SafeDialControl принял неразрешённое имя хоста")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeClientBlocksUnsafeRedirects(t *testing.T) {
|
||||
client := SafeClient(time.Second)
|
||||
origin := &http.Request{URL: &url.URL{Scheme: "https", Host: "example.com"}}
|
||||
via := []*http.Request{origin}
|
||||
|
||||
redirect := &http.Request{URL: &url.URL{Scheme: "http", Host: "127.0.0.1:8080", Path: "/steal"}}
|
||||
if err := client.CheckRedirect(redirect, via); err == nil {
|
||||
t.Fatal("CheckRedirect пропустил переход на внутренний адрес")
|
||||
}
|
||||
// Схема http запрещена всегда: редирект не должен понижать защиту.
|
||||
redirect = &http.Request{URL: &url.URL{Scheme: "http", Host: "example.com"}}
|
||||
if err := client.CheckRedirect(redirect, via); err == nil {
|
||||
t.Fatal("CheckRedirect пропустил переход на http")
|
||||
}
|
||||
redirect = &http.Request{URL: &url.URL{Scheme: "https", Host: "example.com", Path: "/next"}}
|
||||
if err := client.CheckRedirect(redirect, via); err != nil {
|
||||
t.Fatalf("CheckRedirect отклонил обычный https-редирект: %v", err)
|
||||
}
|
||||
// Больше трёх переходов — отказ (защита от цепочек).
|
||||
many := []*http.Request{origin, origin, origin, origin}
|
||||
if err := client.CheckRedirect(redirect, many); err == nil {
|
||||
t.Fatal("CheckRedirect пропустил слишком длинную цепочку")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeTransportHasNoProxy(t *testing.T) {
|
||||
transport := SafeTransport(time.Second)
|
||||
if transport.Proxy != nil {
|
||||
t.Fatal("транспорт не должен использовать прокси из окружения")
|
||||
}
|
||||
if transport.DialContext == nil {
|
||||
t.Fatal("транспорт должен задавать DialContext с проверкой адреса")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user