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
|
||||
}
|
||||
Reference in New Issue
Block a user