7afd23d6d4
Уведомления в браузере и на телефоне: сервер сам решает, кому их слать, и подписывает запрос 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, очистка мёртвых подписок), ручки и лимиты, отправка при личном сообщении и упоминании, пропуск онлайн-получателей, миграция на чистой БД.
147 lines
5.7 KiB
Go
147 lines
5.7 KiB
Go
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
|
|
}
|