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,146 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Проверки адресов для исходящих запросов (AGENT.md 9.2, 9.7): сервер сам
|
||||
// ходит в интернет только там, где это осознанно (Web Push, превью ссылок),
|
||||
// поэтому внутренние подсети должны быть закрыты и для IP-литералов, и для
|
||||
// имён, которые в них разрешаются (DNS rebinding).
|
||||
|
||||
var (
|
||||
// ErrAddressBlocked — адрес ведёт во внутреннюю сеть или не является IP.
|
||||
ErrAddressBlocked = errors.New("address is not allowed")
|
||||
// ErrURLUnsupported — схема, хост или формат URL недопустимы.
|
||||
ErrURLUnsupported = errors.New("url is not supported")
|
||||
)
|
||||
|
||||
// IsBlockedIP сообщает, что адрес нельзя запрашивать: loopback, приватные
|
||||
// диапазоны, link-local (включая метаданные облака 169.254.169.254),
|
||||
// multicast, unspecified и IPv4-mapped варианты приватных адресов.
|
||||
func IsBlockedIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
// IPv4-mapped (::ffff:10.0.0.1) разворачиваем в IPv4: иначе проверки
|
||||
// 4-байтовых диапазонов его не увидят.
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
ip = v4
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
// 0.0.0.0/8 и 100.64.0.0/10 (CGNAT), 192.0.0.0/24 — заведомо не публичные
|
||||
// адреса назначения.
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
return v4[0] == 0 ||
|
||||
(v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127) ||
|
||||
(v4[0] == 192 && v4[1] == 0 && v4[2] == 0)
|
||||
}
|
||||
// IPv6: unique local (fc00::/7) и link-local уже покрыты стандартными
|
||||
// проверками, отдельно закрываем 2001:db8::/32 (диапазон документации).
|
||||
return len(ip) == net.IPv6len && ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x0d && ip[3] == 0xb8
|
||||
}
|
||||
|
||||
// SafeDialControl — функция для net.Dialer.Control: проверяет уже разрешённый
|
||||
// адрес в момент подключения, поэтому подмена DNS между проверкой и запросом
|
||||
// ничего не даёт.
|
||||
func SafeDialControl(_ string, address string, _ syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
host = address
|
||||
}
|
||||
ip := net.ParseIP(strings.Trim(host, "[]"))
|
||||
if ip == nil {
|
||||
return fmt.Errorf("%w: %q is not an ip address", ErrAddressBlocked, address)
|
||||
}
|
||||
if IsBlockedIP(ip) {
|
||||
return fmt.Errorf("%w: %s", ErrAddressBlocked, ip)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SafeTransport собирает транспорт для исходящих запросов: без прокси из
|
||||
// окружения, с проверкой адреса на подключении, с таймаутами и без keep-alive
|
||||
// на чужих хостах.
|
||||
func SafeTransport(timeout time.Duration) *http.Transport {
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
return &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: timeout,
|
||||
KeepAlive: 30 * time.Second,
|
||||
Control: SafeDialControl,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: timeout,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
MaxIdleConns: 4,
|
||||
MaxIdleConnsPerHost: 2,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
}
|
||||
}
|
||||
|
||||
// SafeClient возвращает HTTP-клиент с проверкой адресов и общим таймаутом.
|
||||
func SafeClient(timeout time.Duration) *http.Client {
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: SafeTransport(timeout),
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 3 {
|
||||
return fmt.Errorf("%w: too many redirects", ErrAddressBlocked)
|
||||
}
|
||||
// Каждый хоп проверяем теми же правилами, что и первый запрос:
|
||||
// редирект во внутреннюю сеть — обычный приём SSRF.
|
||||
if _, err := ValidatePublicURL(req.URL.String(), false); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePublicURL проверяет URL для исходящего запроса: разрешены только
|
||||
// http/https (http — если allowHTTP), без userinfo и без литерального
|
||||
// внутреннего адреса. Имя хоста окончательно проверяется при подключении
|
||||
// (SafeDialControl): до резолва доверять ему нельзя.
|
||||
func ValidatePublicURL(raw string, allowHTTP bool) (*url.URL, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" || len(trimmed) > 2048 {
|
||||
return nil, fmt.Errorf("%w: empty or too long", ErrURLUnsupported)
|
||||
}
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrURLUnsupported, err)
|
||||
}
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
if scheme != "https" && (!allowHTTP || scheme != "http") {
|
||||
return nil, fmt.Errorf("%w: scheme %q", ErrURLUnsupported, parsed.Scheme)
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return nil, fmt.Errorf("%w: userinfo is not allowed", ErrURLUnsupported)
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return nil, fmt.Errorf("%w: host is empty", ErrURLUnsupported)
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil && IsBlockedIP(ip) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrAddressBlocked, ip)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
@@ -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