// Package netpolicy applies a network-boundary policy to HTTP clients used to // fetch untrusted resources.
package netpolicy import ( ) // ErrBlockedAddress means a request resolved to a non-public address. var ErrBlockedAddress = errors.New("network policy blocked a non-public address") // Policy controls which network destinations an HTTP client may reach. The // zero value permits only public IP addresses. AllowPrivateNetworks is intended // for trusted diagrams in environments that deliberately serve assets from a // private network. type Policy struct { AllowPrivateNetworks bool } type policyContextKey struct{} // WithPolicy records a policy in ctx for render pipelines that construct their // asset resolver below a public API boundary. func ( context.Context, Policy) context.Context { if == nil { = context.Background() } return context.WithValue(, policyContextKey{}, ) } // FromContext returns the policy stored in ctx, or the public-only zero value. func ( context.Context) Policy { if == nil { return Policy{} } , := .Value(policyContextKey{}).(Policy) return } type ipResolver interface { LookupNetIP(context.Context, string, string) ([]netip.Addr, error) } // NewHTTPClient clones base and applies policy without mutating the caller's // client or transport. Public-only clients resolve each hostname themselves, // reject the entire resolution if any address is non-public, and pass only a // validated numeric address to a policy-owned transport dialer. Caller proxy, // dial, and alternate-protocol hooks are not retained. Every new connection // resolves and validates its destination, so redirects and DNS rebinding cannot // bypass the policy. // // A public-only client requires a standard *http.Transport. Custom protocol // RoundTrippers can opt into trusted private-network behavior explicitly. func ( *http.Client, Policy) (*http.Client, error) { := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second} return newHTTPClient(, , net.DefaultResolver, .DialContext) } // newHTTPClient keeps resolver and dial injection unexported for deterministic // tests. NewHTTPClient always supplies a package-owned net.Dialer. func newHTTPClient( *http.Client, Policy, ipResolver, dialContextFunc) (*http.Client, error) { if == nil { = &http.Client{} } := * if .AllowPrivateNetworks { return &, nil } var *http.Transport switch baseTransport := .Transport.(type) { case nil: = defaultTransport() case *http.Transport: // Clone copies exported configuration but deliberately excludes live // connection pools and handlers installed with RegisterProtocol. = .Clone() default: return nil, fmt.Errorf("public-only network policy requires *http.Transport, got %T", .Transport) } // A proxy resolves or connects to the target outside this transport's dial // boundary, so it cannot uphold the public-only policy. Trusted proxy users // can explicitly opt into private-network behavior. .Proxy = nil .OnProxyConnectResponse = nil .GetProxyConnectHeader = nil .ProxyConnectHeader = nil if == nil || == nil { return nil, errors.New("public-only network policy requires its resolver and dialer") } // Never delegate a validated numeric destination to caller-provided dial // hooks: a hook can ignore that destination and connect to a private address. .Dial = nil .DialContext = validatedDialContext(, ) // Either hook bypasses DialContext for HTTPS. .DialTLS = nil .DialTLSContext = nil // A caller-provided TLSNextProto callback is an arbitrary RoundTripper and // can create connections outside DialContext. Clearing it still permits the // standard library to configure its built-in HTTP/2 implementation. .TLSNextProto = nil .Transport = return &, nil } func defaultTransport() *http.Transport { if , := http.DefaultTransport.(*http.Transport); { return .Clone() } return &http.Transport{ ForceAttemptHTTP2: true, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, } } type dialContextFunc func(context.Context, string, string) (net.Conn, error) func validatedDialContext( ipResolver, dialContextFunc) dialContextFunc { return func( context.Context, , string) (net.Conn, error) { , , := net.SplitHostPort() if != nil { return nil, fmt.Errorf("network policy could not parse destination: %w", ) } , := resolvePublicAddresses(, , , ) if != nil { return nil, } var []error for , := range { , := (, , net.JoinHostPort(.String(), )) if == nil { return , nil } = append(, ) if .Err() != nil { return nil, .Err() } } return nil, errors.Join(...) } } func resolvePublicAddresses( context.Context, ipResolver, , string) ([]netip.Addr, error) { = strings.TrimSuffix(, ".") if isLocalHostname() { return nil, ErrBlockedAddress } var []netip.Addr if , := netip.ParseAddr(); == nil { = []netip.Addr{} } else { := "ip" if strings.HasSuffix(, "4") { = "ip4" } else if strings.HasSuffix(, "6") { = "ip6" } , := .LookupNetIP(, , ) if != nil { return nil, } = } if len() == 0 { return nil, fmt.Errorf("network policy: destination resolved to no addresses") } // Validate every answer before dialing any one of them. A hostname that // mixes public and private answers is rejected instead of falling through to // a private answer after a public connection failure. := make([]netip.Addr, 0, len()) := make(map[netip.Addr]struct{}, len()) for , := range { = .Unmap() if !isPublicAddress() { return nil, ErrBlockedAddress } if , := []; { continue } [] = struct{}{} = append(, ) } return , nil } func isLocalHostname( string) bool { = strings.ToLower() return == "localhost" || strings.HasSuffix(, ".localhost") || == "metadata.google.internal" || strings.HasSuffix(, ".metadata.google.internal") || == "metadata.goog" || strings.HasSuffix(, ".metadata.goog") || == "metadata.azure.internal" || strings.HasSuffix(, ".metadata.azure.internal") || == "instance-data.ec2.internal" || strings.HasSuffix(, ".instance-data.ec2.internal") } var nonPublicPrefixes = []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("168.63.129.16/32"), netip.MustParsePrefix("192.0.0.0/24"), netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("192.88.99.0/24"), netip.MustParsePrefix("198.18.0.0/15"), netip.MustParsePrefix("198.51.100.0/24"), netip.MustParsePrefix("203.0.113.0/24"), netip.MustParsePrefix("240.0.0.0/4"), netip.MustParsePrefix("::/96"), netip.MustParsePrefix("64:ff9b::/96"), netip.MustParsePrefix("64:ff9b:1::/48"), netip.MustParsePrefix("100::/64"), netip.MustParsePrefix("2001::/23"), netip.MustParsePrefix("2001:db8::/32"), netip.MustParsePrefix("2002::/16"), netip.MustParsePrefix("3fff::/20"), netip.MustParsePrefix("5f00::/16"), netip.MustParsePrefix("fec0::/10"), } func isPublicAddress( netip.Addr) bool { if !.IsValid() || .Zone() != "" || !.IsGlobalUnicast() || .IsUnspecified() || .IsLoopback() || .IsPrivate() || .IsLinkLocalUnicast() || .IsLinkLocalMulticast() || .IsMulticast() { return false } for , := range nonPublicPrefixes { if .Contains() { return false } } return true }