// Package netguard builds HTTP clients that refuse to connect to // non-public IP addresses. It exists so features that fetch // externally-controlled URLs (podcast feeds and enclosures, most // notably) cannot be turned into a proxy onto the server's own // loopback interface, its LAN, or cloud-metadata endpoints // (169.254.169.254 and friends) via a malicious or compromised feed // URL, a redirect, or DNS rebinding. // // The check is enforced at dial time, on the specific IP address about // to be connected to, for every connection the client makes — including // ones made while following a redirect. Because the resolved IP is // validated immediately before dialing that exact address (rather than // re-resolving the hostname later), a DNS record that changes between // requests cannot rebind a validated hostname onto a blocked address. package netguard import ( "context" "fmt" "net" "net/http" "net/url" "time" ) // AllowedSchemes are the only URL schemes a guarded client will ever // dial; anything else (file://, ftp://, gopher://, ...) is rejected // before a connection is attempted. var AllowedSchemes = map[string]bool{"http": true, "https": true} // ValidateURL rejects an obviously-bad externally-supplied URL (wrong // scheme, missing host) before it's stored or fetched. This is a // fast, user-facing sanity check; the actual SSRF protection is // enforced at connection time by the client NewSafeHTTPClient returns, // regardless of what passes this check. func ValidateURL(rawURL string) error { u, err := url.Parse(rawURL) if err != nil { return fmt.Errorf("invalid URL: %w", err) } if !AllowedSchemes[u.Scheme] { return fmt.Errorf("URL must be http or https") } if u.Host == "" { return fmt.Errorf("URL must include a host") } return nil } // NewSafeHTTPClient returns an *http.Client that only connects to // public IP addresses, unless allowPrivateNetworks is true (for // deployments that intentionally point podcast feeds at a LAN/self- // hosted server). It also refuses to follow a redirect more than 5 // times or onto a non-http(s) scheme. func NewSafeHTTPClient(timeout time.Duration, allowPrivateNetworks bool) *http.Client { dialer := &net.Dialer{Timeout: 10 * time.Second} transport := &http.Transport{ DialContext: safeDialContext(dialer, allowPrivateNetworks), TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 20 * time.Second, ForceAttemptHTTP2: true, } return &http.Client{ Timeout: timeout, Transport: transport, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 5 { return fmt.Errorf("stopped after 5 redirects") } if !AllowedSchemes[req.URL.Scheme] { return fmt.Errorf("refusing to follow redirect to unsupported scheme %q", req.URL.Scheme) } return nil }, } } func safeDialContext(base *net.Dialer, allowPrivateNetworks bool) func(ctx context.Context, network, addr string) (net.Conn, error) { return func(ctx context.Context, network, addr string) (net.Conn, error) { host, port, err := net.SplitHostPort(addr) if err != nil { return nil, err } // If the host is already a literal IP, LookupIP still handles // it (returning it unchanged) without a real DNS round trip. ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) if err != nil { return nil, err } var lastErr error for _, ip := range ips { if !allowPrivateNetworks && !IsPublicIP(ip) { lastErr = fmt.Errorf("refusing to connect to non-public address %s", ip) continue } conn, dialErr := base.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) if dialErr == nil { return conn, nil } lastErr = dialErr } if lastErr == nil { lastErr = fmt.Errorf("no addresses found for %s", host) } return nil, lastErr } } // IsPublicIP reports whether ip is a globally routable unicast address // that a server-side fetcher should be allowed to connect to. It // rejects loopback, link-local (including the 169.254.169.254 cloud // metadata address), RFC1918/RFC4193 private ranges, carrier-grade NAT // (100.64.0.0/10), multicast, and unspecified addresses. func IsPublicIP(ip net.IP) bool { ip = ip.To16() if ip == nil { return false } if v4 := ip.To4(); v4 != nil { if cgnat.Contains(v4) { return false } } switch { case ip.IsLoopback(), ip.IsPrivate(), ip.IsLinkLocalUnicast(), ip.IsLinkLocalMulticast(), ip.IsInterfaceLocalMulticast(), ip.IsMulticast(), ip.IsUnspecified(): return false } return true } var cgnat = mustParseCIDR("100.64.0.0/10") func mustParseCIDR(s string) *net.IPNet { _, n, err := net.ParseCIDR(s) if err != nil { panic(err) } return n }