Files
nezha_domains/pkg/utils/http.go
T
naibaandnaiba/CloudCode e7c2e453c0 fix(ddns): apply SSRF defense to webhook provider
GHSA-6x26-5727-rrm9: a low-privilege member could point a DDNS webhook
at internal or loopback hosts and the dashboard would dial them with the
unrestricted utils.HttpClient.

Extract the notification SSRF defenses (CIDR blocklist, IP-pin DialContext,
SNI preservation, redirect rejection) into reusable helpers in pkg/utils
(NewRestrictedHTTPClient / ResolveAllowedHTTPURL / buildRestrictedHTTPClient)
and route the DDNS webhook through the same path. Replace the notification
inline implementation with a thin wrapper to keep behaviour identical.

Side improvements collected by the refactor:
- prepareRequest now resolves DNS once and returns the paired client, so
  the dialer's pinned IP and the validated URL stay in sync (no more
  double resolution between prepareRequest and SetRecords).
- response body is drained and closed.
- HttpClient / HttpClientSkipTlsVerify are explicitly tagged unsafe for
  attacker-controlled URLs.

Tests cover: hermetic SNI preservation, redirect rejection, dial pin to
the vetted IP, the full blocked-CIDR list at the webhook entry point,
and the verifyTLS↔skipVerifyTLS inversion in the notification wrapper.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:14:27 +00:00

190 lines
4.4 KiB
Go

package utils
import (
"context"
"crypto/tls"
"errors"
"net"
"net/http"
"net/netip"
"net/url"
"time"
)
// HttpClient / HttpClientSkipTlsVerify must not be used to dispatch
// requests to user-controlled URLs (SSRF risk, GHSA-6x26-5727-rrm9).
// For any attacker-controlled URL use NewRestrictedHTTPClient instead.
var (
HttpClientSkipTlsVerify *http.Client
HttpClient *http.Client
)
var ErrHTTPURLTargetNotAllowed = errors.New("HTTP URL target is not allowed")
var blockedHTTPClientCIDRs = mustParseHTTPClientCIDRs([]string{
"0.0.0.0/8",
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.0.0.0/24",
"192.0.2.0/24",
"192.168.0.0/16",
"198.18.0.0/15",
"198.51.100.0/24",
"203.0.113.0/24",
"224.0.0.0/4",
"240.0.0.0/4",
"::/128",
"::1/128",
"::ffff:0:0/96",
"64:ff9b::/96",
"100::/64",
"2001::/23",
"2001:db8::/32",
"fc00::/7",
"fe80::/10",
"ff00::/8",
})
func init() {
HttpClientSkipTlsVerify = httpClient(_httpClient{
Transport: httpTransport(_httpTransport{
SkipVerifyTLS: true,
}),
})
HttpClient = httpClient(_httpClient{
Transport: httpTransport(_httpTransport{
SkipVerifyTLS: false,
}),
})
http.DefaultClient.Timeout = time.Minute * 10
}
type _httpTransport struct {
SkipVerifyTLS bool
}
func httpTransport(conf _httpTransport) *http.Transport {
return &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: conf.SkipVerifyTLS},
Proxy: http.ProxyFromEnvironment,
}
}
type _httpClient struct {
Transport *http.Transport
}
func httpClient(conf _httpClient) *http.Client {
return &http.Client{
Transport: conf.Transport,
Timeout: time.Minute * 10,
}
}
func NewRestrictedHTTPClient(rawURL string, skipVerifyTLS bool) (*http.Client, error) {
parsedURL, ip, err := ResolveAllowedHTTPURL(rawURL)
if err != nil {
return nil, err
}
return buildRestrictedHTTPClient(parsedURL, ip, skipVerifyTLS), nil
}
// buildRestrictedHTTPClient assembles a client whose DialContext is pinned to
// the already-vetted IP. Separated from NewRestrictedHTTPClient so tests can
// exercise the SNI / redirect behavior without relying on live DNS.
func buildRestrictedHTTPClient(parsedURL *url.URL, ip net.IP, skipVerifyTLS bool) *http.Client {
port := parsedURL.Port()
if port == "" {
if parsedURL.Scheme == "https" {
port = "443"
} else {
port = "80"
}
}
// Pin outbound webhooks to the vetted IP so DNS changes cannot retarget private hosts.
targetAddress := net.JoinHostPort(ip.String(), port)
dialer := &net.Dialer{}
return &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
return dialer.DialContext(ctx, network, targetAddress)
},
TLSClientConfig: &tls.Config{InsecureSkipVerify: skipVerifyTLS, ServerName: parsedURL.Hostname()},
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Timeout: time.Minute * 10,
}
}
func ResolveAllowedHTTPURL(rawURL string) (*url.URL, net.IP, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return nil, nil, err
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return nil, nil, ErrHTTPURLTargetNotAllowed
}
host := parsedURL.Hostname()
if host == "" {
return nil, nil, ErrHTTPURLTargetNotAllowed
}
if ip := net.ParseIP(host); ip != nil {
if !HTTPURLTargetIPAllowed(ip) {
return nil, nil, ErrHTTPURLTargetNotAllowed
}
return parsedURL, ip, nil
}
ips, err := net.LookupIP(host)
if err != nil {
return nil, nil, err
}
if len(ips) == 0 {
return nil, nil, ErrHTTPURLTargetNotAllowed
}
for _, ip := range ips {
if !HTTPURLTargetIPAllowed(ip) {
return nil, nil, ErrHTTPURLTargetNotAllowed
}
}
return parsedURL, ips[0], nil
}
func HTTPURLTargetIPAllowed(ip net.IP) bool {
parsedIP, ok := netipFromIP(ip)
if !ok {
return false
}
for _, cidr := range blockedHTTPClientCIDRs {
if cidr.Contains(parsedIP) {
return false
}
}
return parsedIP.IsGlobalUnicast()
}
func netipFromIP(ip net.IP) (netip.Addr, bool) {
parsedIP, ok := netip.AddrFromSlice(ip)
if !ok {
return netip.Addr{}, false
}
return parsedIP.Unmap(), true
}
func mustParseHTTPClientCIDRs(cidrs []string) []netip.Prefix {
prefixes := make([]netip.Prefix, 0, len(cidrs))
for _, cidr := range cidrs {
prefixes = append(prefixes, netip.MustParsePrefix(cidr))
}
return prefixes
}