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 }