|
| 1 | +package httpclient |
| 2 | + |
| 3 | +import ( |
| 4 | + "crypto/tls" |
| 5 | + "fmt" |
| 6 | + "net" |
| 7 | + "net/http" |
| 8 | + "strings" |
| 9 | + "syscall" |
| 10 | + "time" |
| 11 | +) |
| 12 | + |
| 13 | +// cgnat is the RFC 6598 carrier-grade NAT shared address space |
| 14 | +// (100.64.0.0/10). Go's net.IP.IsPrivate does NOT cover it, but it is |
| 15 | +// non-public and a valid SSRF pivot, so the guard blocks it explicitly. |
| 16 | +var cgnat = &net.IPNet{IP: net.IPv4(100, 64, 0, 0).To4(), Mask: net.CIDRMask(10, 32)} |
| 17 | + |
| 18 | +// BlockedIP reports whether ip is in a range that outbound requests to |
| 19 | +// operator- or IdP-supplied URLs must not reach (SSRF). Covers loopback, |
| 20 | +// RFC1918 private, RFC6598 CGNAT, link-local (including the 169.254.169.254 |
| 21 | +// cloud-metadata endpoint), and the unspecified address. IPv4-mapped IPv6 |
| 22 | +// forms are unwrapped before checking. A nil/unparseable ip is blocked |
| 23 | +// (fail closed). |
| 24 | +func BlockedIP(ip net.IP) bool { |
| 25 | + if ip == nil { |
| 26 | + return true |
| 27 | + } |
| 28 | + if v4 := ip.To4(); v4 != nil { |
| 29 | + ip = v4 |
| 30 | + } |
| 31 | + return ip.IsLoopback() || |
| 32 | + ip.IsPrivate() || |
| 33 | + ip.IsLinkLocalUnicast() || |
| 34 | + ip.IsLinkLocalMulticast() || |
| 35 | + ip.IsUnspecified() || |
| 36 | + cgnat.Contains(ip) |
| 37 | +} |
| 38 | + |
| 39 | +// BlockedHost rejects targets that are non-public by name — literal IPs and |
| 40 | +// obvious loopback names — before DNS resolution. The dial-time |
| 41 | +// GuardedDialControl re-checks the resolved IP (defeats DNS rebinding). |
| 42 | +func BlockedHost(host string) bool { |
| 43 | + if ip := net.ParseIP(host); ip != nil { |
| 44 | + return BlockedIP(ip) |
| 45 | + } |
| 46 | + lower := strings.ToLower(host) |
| 47 | + return lower == "localhost" || strings.HasSuffix(lower, ".localhost") |
| 48 | +} |
| 49 | + |
| 50 | +// GuardedDialControl runs after DNS resolution with the concrete dial |
| 51 | +// address and blocks the connection if the resolved IP is non-public. Wire |
| 52 | +// it into a net.Dialer.Control so a hostname that resolves (or rebinds) to |
| 53 | +// internal space cannot be reached. |
| 54 | +func GuardedDialControl(_, address string, _ syscall.RawConn) error { |
| 55 | + host, _, err := net.SplitHostPort(address) |
| 56 | + if err != nil { |
| 57 | + host = address |
| 58 | + } |
| 59 | + if ip := net.ParseIP(host); ip != nil && BlockedIP(ip) { |
| 60 | + return fmt.Errorf("httpclient: blocked dial to non-public address %s", host) |
| 61 | + } |
| 62 | + return nil |
| 63 | +} |
| 64 | + |
| 65 | +// NewGuardedHTTPClient returns a *http.Client that refuses to dial |
| 66 | +// non-public addresses (SSRF guard) and pins TLS >= 1.2. Use for outbound |
| 67 | +// calls to operator- or IdP-supplied URLs (webhooks, OIDC discovery/JWKS). |
| 68 | +func NewGuardedHTTPClient(timeout time.Duration) *http.Client { |
| 69 | + return &http.Client{ |
| 70 | + Timeout: timeout, |
| 71 | + Transport: &http.Transport{ |
| 72 | + DialContext: (&net.Dialer{ |
| 73 | + Timeout: 10 * time.Second, |
| 74 | + Control: GuardedDialControl, |
| 75 | + }).DialContext, |
| 76 | + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, |
| 77 | + ForceAttemptHTTP2: true, |
| 78 | + }, |
| 79 | + } |
| 80 | +} |
| 81 | + |
| 82 | +// NewGuardedClient wraps NewGuardedHTTPClient in the correlation-forwarding |
| 83 | +// Client. Use for outbound calls to untrusted URLs that should still carry |
| 84 | +// the correlation ID (the OIDC flow). |
| 85 | +func NewGuardedClient(timeout time.Duration) *Client { |
| 86 | + return WithInner(NewGuardedHTTPClient(timeout)) |
| 87 | +} |
0 commit comments