Files
glchat/internal/httpx/safeurl.go
T

147 lines
5.7 KiB
Go
Raw Normal View History

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
}