From cf48721888f5344a8ee020b75f73946c9741a8c3 Mon Sep 17 00:00:00 2001 From: Tharindu Dharmarathna Date: Thu, 20 Aug 2026 22:57:12 +0530 Subject: [PATCH] httpclient implementation --- httpkit/httpclient/client.go | 363 +++++++++++++++++++ httpkit/httpclient/client_test.go | 138 +++++++ httpkit/httpclient/doc.go | 32 ++ httpkit/httpclient/helpers_test.go | 76 ++++ httpkit/httpclient/proxy.go | 142 ++++++++ httpkit/httpclient/proxy_test.go | 116 ++++++ httpkit/httpclient/roundtrip_connect.go | 215 +++++++++++ httpkit/httpclient/roundtrip_connect_test.go | 118 ++++++ httpkit/httpclient/tls.go | 160 ++++++++ httpkit/httpclient/tls_test.go | 169 +++++++++ httpkit/httpclient/transport.go | 108 ++++++ httpkit/httpclient/transport_connect_test.go | 196 ++++++++++ httpkit/httpclient/transport_test.go | 87 +++++ httpkit/httpclient/transport_tier2_test.go | 127 +++++++ httpkit/netguard/netguard.go | 264 ++++++++++++++ httpkit/netguard/netguard_test.go | 223 ++++++++++++ httpkit/netguard/presets.go | 37 ++ httpkit/tlsconfig/tlsconfig.go | 164 +++++++++ httpkit/tlsconfig/tlsconfig_test.go | 125 +++++++ 19 files changed, 2860 insertions(+) create mode 100644 httpkit/httpclient/client.go create mode 100644 httpkit/httpclient/client_test.go create mode 100644 httpkit/httpclient/doc.go create mode 100644 httpkit/httpclient/helpers_test.go create mode 100644 httpkit/httpclient/proxy.go create mode 100644 httpkit/httpclient/proxy_test.go create mode 100644 httpkit/httpclient/roundtrip_connect.go create mode 100644 httpkit/httpclient/roundtrip_connect_test.go create mode 100644 httpkit/httpclient/tls.go create mode 100644 httpkit/httpclient/tls_test.go create mode 100644 httpkit/httpclient/transport.go create mode 100644 httpkit/httpclient/transport_connect_test.go create mode 100644 httpkit/httpclient/transport_test.go create mode 100644 httpkit/httpclient/transport_tier2_test.go create mode 100644 httpkit/netguard/netguard.go create mode 100644 httpkit/netguard/netguard_test.go create mode 100644 httpkit/netguard/presets.go create mode 100644 httpkit/tlsconfig/tlsconfig.go create mode 100644 httpkit/tlsconfig/tlsconfig_test.go diff --git a/httpkit/httpclient/client.go b/httpkit/httpclient/client.go new file mode 100644 index 0000000000..bdba112aa5 --- /dev/null +++ b/httpkit/httpclient/client.go @@ -0,0 +1,363 @@ +package httpclient + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "net/http" + "net/url" + "time" + + "github.com/wso2/go-httpkit/netguard" +) + +// Config describes how to build an outbound *http.Client. Use DefaultConfig +// to obtain sane pooling/timeout defaults, then override only the fields a +// caller needs to change — the TLS, Proxy, and SSRF zero values are all +// meaningful ("no client cert", "no proxy", "no SSRF guard") rather than +// placeholders that must be filled in. +type Config struct { + Pooling PoolingConfig + Timeouts TimeoutsConfig + TLS TLSConfig + Proxy ProxyConfig + SSRF SSRFConfig +} + +// PoolingConfig controls http.Transport's connection pooling behavior. +type PoolingConfig struct { + // MaxIdleConns, MaxIdleConnsPerHost, and MaxConnsPerHost mirror the + // identically-named http.Transport fields. + MaxIdleConns int + MaxIdleConnsPerHost int + MaxConnsPerHost int + // IdleConnTimeout mirrors http.Transport.IdleConnTimeout. + IdleConnTimeout time.Duration + // KeepAlive mirrors net.Dialer.KeepAlive for the underlying TCP dialer. + KeepAlive time.Duration + // DisableKeepAlives mirrors http.Transport.DisableKeepAlives. Reusing + // pooled connections carries no SSRF/DNS-rebinding risk (see the + // netguard package doc), so this defaults to false even when the SSRF + // guard is enabled. + DisableKeepAlives bool + // EnableHTTP2 opts into HTTP/2. It defaults to false: this package + // always sets a custom DialContext and/or TLSClientConfig, which makes + // Go's own Transport conservatively disable HTTP/2 unless explicitly + // re-enabled — and HTTP/2 connection coalescing (RFC 7540 §9.1.1) can + // reuse an established connection for a different, SAN-covered hostname + // without a fresh DialContext validation. Only enable this if that + // tradeoff has been considered for the caller's use case. + EnableHTTP2 bool +} + +// TimeoutsConfig bounds every phase of an outbound request. Per +// go-network-service-hardening.md, DefaultConfig never leaves these at +// Go's unbounded zero values. +type TimeoutsConfig struct { + // Overall mirrors http.Client.Timeout — the end-to-end budget for a + // single request including any redirects. + Overall time.Duration + // Dial bounds the TCP connect phase. + Dial time.Duration + // TLSHandshake mirrors http.Transport.TLSHandshakeTimeout. + TLSHandshake time.Duration + // ResponseHeader mirrors http.Transport.ResponseHeaderTimeout. + ResponseHeader time.Duration + // ExpectContinue mirrors http.Transport.ExpectContinueTimeout. + ExpectContinue time.Duration + // MaxResponseBytes bounds how much of a response body the returned + // client will read before erroring, so an oversized or hostile response + // cannot exhaust memory. 0 uses the package default + // (defaultMaxResponseBytes); a negative value disables the bound + // entirely (opt-in, for callers that stream large trusted payloads). + MaxResponseBytes int64 +} + +// TLSConfig controls the TLS handshake used for the ORIGIN connection — +// i.e. the server ultimately being talked to, whether reached directly or +// through a CONNECT-tunneling proxy. See ProxyTLSConfig for the separate, +// proxy-facing TLS handshake. +type TLSConfig struct { + // MinVersion and MaxVersion use the tlsconfig.ParseVersion vocabulary + // ("TLS1_0".."TLS1_3"). Both empty uses Go's own defaults. + MinVersion, MaxVersion string + // CipherSuites is a comma-separated list of Go crypto/tls cipher suite + // names. Empty uses Go's own default secure set. Only affects TLS 1.2 + // and below. + CipherSuites string + // CurvePreferences is a comma-separated, order-significant list of + // curve/group names, e.g. "X25519MLKEM768,X25519,P-256" to prefer the + // FIPS 203 ML-KEM-768 hybrid group while retaining classical fallbacks + // for a peer that doesn't support it yet. Empty uses Go's own defaults + // (no PQC) — enabling a hybrid group is always an explicit opt-in here, + // never this package's unconditional default. + CurvePreferences string + + // RootCAFile loads a PEM-encoded CA bundle from disk. RootCAs, if set, + // takes precedence and is used as-is (e.g. for a caller that already + // manages certificate rotation itself). Both empty uses Go's system + // root pool. + RootCAFile string + RootCAs *x509.CertPool + + // ClientCertFile/ClientKeyFile load a PEM client certificate/key pair + // for mTLS to the origin. GetClientCertificate, if set, is used instead + // (e.g. for rotation) and the two file fields are ignored. + ClientCertFile, ClientKeyFile string + GetClientCertificate func(*tls.CertificateRequestInfo) (*tls.Certificate, error) + + // InsecureSkipVerify disables certificate chain and hostname + // verification entirely. It is a narrow, explicitly-named, off-by- + // default escape hatch: New returns an error unless + // InsecureSkipVerifyAcknowledged is also true, and it can never be + // combined with VerifyPeerCertificate/VerifyConnection (see below). + InsecureSkipVerify bool + InsecureSkipVerifyAcknowledged bool + + // VerifyPeerCertificate and VerifyConnection are run IN ADDITION TO, + // never instead of, Go's own default verification — New rejects either + // one being set alongside InsecureSkipVerify == true, since a custom + // callback would then silently become the only check performed (its + // verifiedChains argument is empty when default verification didn't + // run). Use these only to add an extra check (e.g. certificate + // pinning) on top of a chain Go has already verified. + VerifyPeerCertificate func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error + VerifyConnection func(tls.ConnectionState) error +} + +// ProxyConfig configures forward-proxy use. +type ProxyConfig struct { + // Mode selects how the proxy is determined: "none" (default, no proxy), + // "environment" (use HTTP_PROXY/HTTPS_PROXY/NO_PROXY via Go's own + // http.ProxyFromEnvironment), or "url" (use URL below, with NoProxy as + // an explicit bypass list). + Mode string + // URL is the proxy URL, used when Mode == "url". + URL string + // Username and Password set basic auth credentials for the proxy + // connection (Proxy-Authorization), used when Mode == "url". + Username, Password string + // NoProxy lists hosts to bypass the proxy for, used when Mode == "url". + // Each entry is an exact host, a ".suffix" domain match, or a CIDR. + NoProxy []string + + // ProxyTLS configures a SEPARATE, distinct TLS handshake to an + // https:// proxy itself (e.g. the proxy requires its own client + // certificate, different from TLS.ClientCertFile/Key used for the + // origin). Leave nil for the common case where either the proxy is + // plain (http://) or the proxy's own TLS needs no client cert. + ProxyTLS *ProxyTLSConfig + + // ConnectHeader, if set, supplies additional headers on the CONNECT + // request to the proxy (e.g. a bearer-token proxy-auth scheme) — + // passed through to http.Transport.GetProxyConnectHeader. + ConnectHeader func(ctx context.Context, proxyURL *url.URL, target string) (http.Header, error) + + // Egress must be set explicitly whenever Mode != "none" AND + // SSRF.Enabled — see the package doc for why a dial-time IP guard + // cannot protect the proxied origin. + Egress ProxyEgressPolicy +} + +// ProxyTLSConfig configures the TLS handshake to the proxy itself, fully +// decoupled from TLSConfig (which always governs the origin handshake). +type ProxyTLSConfig struct { + // RootCAFile loads a PEM-encoded CA bundle from disk. RootCAs, if set, + // takes precedence. Both empty uses Go's system root pool. + RootCAFile string + RootCAs *x509.CertPool + + // ClientCertFile/ClientKeyFile load a PEM client certificate/key pair + // for mTLS to the proxy. GetClientCertificate, if set, is used instead + // and the two file fields are ignored. + ClientCertFile, ClientKeyFile string + GetClientCertificate func(*tls.CertificateRequestInfo) (*tls.Certificate, error) + + InsecureSkipVerify bool + InsecureSkipVerifyAcknowledged bool +} + +// ProxyEgressPolicy states how origin-destination SSRF risk is handled when +// a forward proxy is also configured. +type ProxyEgressPolicy int + +const ( + // ProxyEgressUnset is the zero value. New returns an error if this is + // left unset while both a proxy and the SSRF guard are configured — + // this policy must always be a deliberate choice, never a default. + ProxyEgressUnset ProxyEgressPolicy = iota + // ProxyEgressDelegated trusts the proxy's own network egress controls + // for the proxied origin. The dial-time guard still validates the + // proxy's own resolved address (and CheckRedirect still applies its + // scheme/host policy), but the origin itself is not validated by this + // library while proxying. + ProxyEgressDelegated + // ProxyEgressManualCONNECT gives up http.Transport's native proxy + // support and uses a hand-rolled http.RoundTripper that resolves and + // validates the origin hostname itself, locally, before ever issuing a + // CONNECT request. This is defense-in-depth against what this process + // itself would resolve — it does not guarantee the proxy resolves or + // routes the origin the same way. + ProxyEgressManualCONNECT +) + +// SSRFConfig configures the dial-time SSRF guard (see the netguard +// package). It is disabled by default: enabling it is always an explicit, +// per-caller choice, since two legitimate use cases already in this +// codebase disagree on which addresses should be reachable. +type SSRFConfig struct { + // Enabled turns the guard on. When true, Policy must be a non-zero + // netguard.Policy — New returns an error otherwise, rather than + // silently falling back to one preset over another. + Enabled bool + Policy netguard.Policy + // MaxRedirects bounds redirect hops. 0 uses netguard's own default (5); + // a negative value is not distinguished from zero. + MaxRedirects int +} + +// buildState accumulates the effect of functional Options during New. +type buildState struct { + roundTripperWrappers []func(http.RoundTripper) http.RoundTripper + dialOverride func(ctx context.Context, network, addr string) (net.Conn, error) +} + +// Option customizes client construction with a hook that cannot be +// expressed as plain configuration data. +type Option func(*buildState) + +// WithRoundTripperWrapper wraps the built http.RoundTripper with wrap, +// closest to Client.Do — e.g. for attaching metrics/tracing instrumentation. +// Wrappers are applied in the order given. +func WithRoundTripperWrapper(wrap func(http.RoundTripper) http.RoundTripper) Option { + return func(s *buildState) { + s.roundTripperWrappers = append(s.roundTripperWrappers, wrap) + } +} + +// WithDialContext overrides the dial function New would otherwise choose +// (plain or netguard-guarded). Intended for tests; using it bypasses +// whatever SSRF policy Config.SSRF would otherwise have applied. +func WithDialContext(dial func(ctx context.Context, network, addr string) (net.Conn, error)) Option { + return func(s *buildState) { + s.dialOverride = dial + } +} + +// defaultMaxResponseBytes bounds a response body when TimeoutsConfig +// doesn't specify one, per go-network-service-hardening.md's requirement +// that inbound readers are never left unbounded. +const defaultMaxResponseBytes int64 = 10 << 20 // 10 MiB + +// DefaultConfig returns a Config with sane, non-zero pooling and timeout +// defaults. TLS, Proxy, and SSRF are left at their zero values (no client +// cert, no proxy, no SSRF guard) for the caller to opt into explicitly. +func DefaultConfig() Config { + return Config{ + Pooling: PoolingConfig{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + MaxConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + KeepAlive: 30 * time.Second, + }, + Timeouts: TimeoutsConfig{ + Overall: 30 * time.Second, + Dial: 10 * time.Second, + TLSHandshake: 10 * time.Second, + ResponseHeader: 10 * time.Second, + ExpectContinue: 1 * time.Second, + MaxResponseBytes: defaultMaxResponseBytes, + }, + } +} + +// New builds an *http.Client from cfg. It validates cfg up front and fails +// closed on any ambiguous or unsafe combination (see the package doc) +// rather than silently choosing a default stance. +func New(cfg Config, opts ...Option) (*http.Client, error) { + state := &buildState{} + for _, opt := range opts { + opt(state) + } + + if cfg.SSRF.Enabled && isZeroPolicy(cfg.SSRF.Policy) { + return nil, fmt.Errorf("httpclient: SSRF.Enabled requires a non-zero SSRF.Policy (see netguard.PermitPrivateBlockMetadata / netguard.PublicOnly)") + } + if cfg.Proxy.Mode != "" && cfg.Proxy.Mode != "none" && cfg.SSRF.Enabled && cfg.Proxy.Egress == ProxyEgressUnset { + return nil, fmt.Errorf("httpclient: Proxy and SSRF are both configured — Proxy.Egress must be set explicitly to ProxyEgressDelegated or ProxyEgressManualCONNECT (a dial-time guard cannot see the proxied origin, only the proxy's own address)") + } + + originTLS, err := buildTLSConfig(cfg.TLS) + if err != nil { + return nil, err + } + + var proxyTLS *tls.Config + if cfg.Proxy.ProxyTLS != nil { + proxyTLS, err = buildProxyTLSConfig(*cfg.Proxy.ProxyTLS) + if err != nil { + return nil, err + } + } + + dialFn := state.dialOverride + if dialFn == nil { + if cfg.SSRF.Enabled { + dialFn = netguard.DialContext(cfg.SSRF.Policy, cfg.Timeouts.Dial) + } else { + dialFn = (&net.Dialer{Timeout: cfg.Timeouts.Dial, KeepAlive: cfg.Pooling.KeepAlive}).DialContext + } + } + + var rt http.RoundTripper + if cfg.Proxy.Egress == ProxyEgressManualCONNECT { + rt, err = newConnectRoundTripper(cfg, dialFn, originTLS) + if err != nil { + return nil, err + } + } else { + rt, err = buildTransport(cfg, dialFn, originTLS, proxyTLS) + if err != nil { + return nil, err + } + } + + maxBytes := cfg.Timeouts.MaxResponseBytes + if maxBytes == 0 { + maxBytes = defaultMaxResponseBytes + } + if maxBytes > 0 { + rt = &maxBytesRoundTripper{next: rt, max: maxBytes} + } + + for _, wrap := range state.roundTripperWrappers { + rt = wrap(rt) + } + + client := &http.Client{ + Transport: rt, + Timeout: cfg.Timeouts.Overall, + } + if cfg.SSRF.Enabled { + redirectPolicy := cfg.SSRF.Policy + if len(redirectPolicy.AllowedSchemes) == 0 { + redirectPolicy.AllowedSchemes = []string{"https"} + } + client.CheckRedirect = netguard.CheckRedirect(redirectPolicy, cfg.SSRF.MaxRedirects) + } + + return client, nil +} + +// isZeroPolicy reports whether p is the zero-value Policy — used to detect +// a caller enabling SSRF.Enabled without picking a preset. Policy contains +// slice fields, so it cannot be compared with ==; every field is checked +// individually instead. +func isZeroPolicy(p netguard.Policy) bool { + return !p.BlockPrivate && !p.BlockLoopback && !p.BlockLinkLocal && !p.BlockUnspecified && + !p.BlockMulticastBroadcast && !p.BlockCGNAT && + len(p.DenyCIDRs) == 0 && len(p.AllowCIDRs) == 0 && len(p.AllowedSchemes) == 0 +} diff --git a/httpkit/httpclient/client_test.go b/httpkit/httpclient/client_test.go new file mode 100644 index 0000000000..a37d7b9dc8 --- /dev/null +++ b/httpkit/httpclient/client_test.go @@ -0,0 +1,138 @@ +package httpclient + +import ( + "crypto/tls" + "testing" + + "github.com/wso2/go-httpkit/netguard" +) + +func TestDefaultConfig_NeverUnboundedPoolingOrTimeouts(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Pooling.MaxIdleConns <= 0 { + t.Error("DefaultConfig: MaxIdleConns must be positive") + } + if cfg.Pooling.MaxIdleConnsPerHost <= 0 { + t.Error("DefaultConfig: MaxIdleConnsPerHost must be positive") + } + if cfg.Pooling.IdleConnTimeout <= 0 { + t.Error("DefaultConfig: IdleConnTimeout must be positive") + } + if cfg.Timeouts.Overall <= 0 { + t.Error("DefaultConfig: Timeouts.Overall must be positive") + } + if cfg.Timeouts.Dial <= 0 { + t.Error("DefaultConfig: Timeouts.Dial must be positive") + } + if cfg.Timeouts.TLSHandshake <= 0 { + t.Error("DefaultConfig: Timeouts.TLSHandshake must be positive") + } + if cfg.Timeouts.ResponseHeader <= 0 { + t.Error("DefaultConfig: Timeouts.ResponseHeader must be positive") + } + if cfg.Timeouts.MaxResponseBytes <= 0 { + t.Error("DefaultConfig: Timeouts.MaxResponseBytes must be positive") + } + if cfg.SSRF.Enabled { + t.Error("DefaultConfig: SSRF must be disabled by default (opt-in)") + } +} + +func TestNew_BuildsAClientForDefaultConfig(t *testing.T) { + client, err := New(DefaultConfig()) + if err != nil { + t.Fatalf("New(DefaultConfig()) unexpected error: %v", err) + } + if client == nil { + t.Fatal("New(DefaultConfig()) returned a nil client") + } + if client.Transport == nil { + t.Fatal("client.Transport is nil") + } +} + +func TestNew_InsecureSkipVerifyRequiresAcknowledgement(t *testing.T) { + cfg := DefaultConfig() + cfg.TLS.InsecureSkipVerify = true + // InsecureSkipVerifyAcknowledged left false. + if _, err := New(cfg); err == nil { + t.Fatal("expected New to reject InsecureSkipVerify without acknowledgement") + } + + cfg.TLS.InsecureSkipVerifyAcknowledged = true + if _, err := New(cfg); err != nil { + t.Fatalf("expected New to accept InsecureSkipVerify with acknowledgement, got: %v", err) + } +} + +func TestNew_InsecureSkipVerifyRejectsCustomVerifyCallback(t *testing.T) { + cfg := DefaultConfig() + cfg.TLS.InsecureSkipVerify = true + cfg.TLS.InsecureSkipVerifyAcknowledged = true + cfg.TLS.VerifyConnection = func(cs tls.ConnectionState) error { return nil } + if _, err := New(cfg); err == nil { + t.Fatal("expected New to reject InsecureSkipVerify combined with a custom VerifyConnection callback") + } +} + +func TestNew_SSRFEnabledRequiresNonZeroPolicy(t *testing.T) { + cfg := DefaultConfig() + cfg.SSRF.Enabled = true + // Policy left zero-value. + if _, err := New(cfg); err == nil { + t.Fatal("expected New to reject SSRF.Enabled with a zero-value Policy") + } + + cfg.SSRF.Policy = netguard.PermitPrivateBlockMetadata() + if _, err := New(cfg); err != nil { + t.Fatalf("expected New to accept SSRF.Enabled with a preset policy, got: %v", err) + } +} + +func TestNew_ProxyPlusSSRFRequiresExplicitEgress(t *testing.T) { + cfg := DefaultConfig() + cfg.SSRF.Enabled = true + cfg.SSRF.Policy = netguard.PermitPrivateBlockMetadata() + cfg.Proxy.Mode = "environment" + // Egress left at ProxyEgressUnset. + if _, err := New(cfg); err == nil { + t.Fatal("expected New to reject Proxy+SSRF configured together without an explicit Egress choice") + } + + cfg.Proxy.Egress = ProxyEgressDelegated + if _, err := New(cfg); err != nil { + t.Fatalf("expected New to accept Proxy+SSRF with ProxyEgressDelegated, got: %v", err) + } +} + +func TestNew_ProxyWithoutSSRFDoesNotRequireEgress(t *testing.T) { + cfg := DefaultConfig() + cfg.Proxy.Mode = "environment" + // SSRF disabled entirely: Egress should not be required. + if _, err := New(cfg); err != nil { + t.Fatalf("expected New to accept a proxy with SSRF disabled and no Egress choice, got: %v", err) + } +} + +func TestNew_RejectsUnknownProxyMode(t *testing.T) { + cfg := DefaultConfig() + cfg.Proxy.Mode = "bogus" + if _, err := New(cfg); err == nil { + t.Fatal("expected New to reject an unknown Proxy.Mode") + } +} + +func TestNew_RejectsBadCipherOrCurveNames(t *testing.T) { + cfg := DefaultConfig() + cfg.TLS.CipherSuites = "NOT_A_REAL_SUITE" + if _, err := New(cfg); err == nil { + t.Fatal("expected New to reject an unknown cipher suite name") + } + + cfg = DefaultConfig() + cfg.TLS.CurvePreferences = "NotACurve" + if _, err := New(cfg); err == nil { + t.Fatal("expected New to reject an unknown curve name") + } +} diff --git a/httpkit/httpclient/doc.go b/httpkit/httpclient/doc.go new file mode 100644 index 0000000000..04d122df20 --- /dev/null +++ b/httpkit/httpclient/doc.go @@ -0,0 +1,32 @@ +// Package httpclient builds a secure-by-default outbound *http.Client for +// use by any Go component in this repo. It composes connection pooling, +// forward-proxy support (including mTLS tunneled through a CONNECT proxy), +// fine-grained TLS control (cipher suites, ECDH/curve preferences including +// post-quantum hybrid groups), and an optional SSRF dial-time guard, while +// keeping hostname verification a property that cannot be silently +// disabled. +// +// # Hostname verification +// +// Config never exposes a fixed tls.Config.ServerName: Go's own TLS dialing +// only fills that field in per-target when it is left empty, and a client +// built by this package is expected to be reused across many hosts. Setting +// TLS.InsecureSkipVerify requires also setting +// TLS.InsecureSkipVerifyAcknowledged — flipping one boolean is not enough to +// disable verification. A custom TLS.VerifyPeerCertificate or +// VerifyConnection may only be combined with InsecureSkipVerify == false; +// New returns an error otherwise, since a custom callback silently becomes +// the *only* check once Go's own verification is disabled. +// +// # SSRF guard composed with a forward proxy +// +// A dial-time IP guard (see the netguard package) can only validate the +// address this process itself dials. When a forward proxy is configured, +// that is the proxy's address, never the proxied origin's — the origin +// hostname is only ever placed in a CONNECT request line, generated after +// the guarded dial has already completed. Configuring both SSRF.Enabled and +// a non-"none" Proxy.Mode therefore requires an explicit Proxy.Egress +// choice; New returns an error if it is left at its zero value +// (ProxyEgressUnset), so that a caller can never end up believing the +// origin is SSRF-protected when it silently isn't. +package httpclient diff --git a/httpkit/httpclient/helpers_test.go b/httpkit/httpclient/helpers_test.go new file mode 100644 index 0000000000..1b0ba4972f --- /dev/null +++ b/httpkit/httpclient/helpers_test.go @@ -0,0 +1,76 @@ +package httpclient + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net" + "testing" + "time" +) + +// selfSignedCert is a self-signed certificate usable both as a leaf +// (presented on a connection) and, by adding certDER to a pool, as its own +// trust root — the same pattern net/http/httptest uses for its own test +// certificates. +type selfSignedCert struct { + tlsCert tls.Certificate + leaf *x509.Certificate + pool *x509.CertPool +} + +// newSelfSignedCert generates an ECDSA P-256 self-signed certificate with +// the given CommonName, DNS names, and IP addresses, valid for both server +// and client authentication so the same helper covers origin certs, proxy +// certs, and client certs across the test suite. commonName is distinct per +// call so tests that need to tell two certificates apart (e.g. "did the +// proxy see the proxy cert or the origin cert?") can assert on it. +func newSelfSignedCert(t *testing.T, commonName string, dnsNames []string, ips []net.IP) selfSignedCert { + t.Helper() + + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + + serial, err := rand.Int(rand.Reader, big.NewInt(1<<62)) + if err != nil { + t.Fatalf("rand.Int: %v", err) + } + + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: commonName}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + IsCA: true, + DNSNames: dnsNames, + IPAddresses: ips, + } + + der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) + if err != nil { + t.Fatalf("CreateCertificate: %v", err) + } + + leaf, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("ParseCertificate: %v", err) + } + + pool := x509.NewCertPool() + pool.AddCert(leaf) + + return selfSignedCert{ + tlsCert: tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv, Leaf: leaf}, + leaf: leaf, + pool: pool, + } +} diff --git a/httpkit/httpclient/proxy.go b/httpkit/httpclient/proxy.go new file mode 100644 index 0000000000..1559f3e947 --- /dev/null +++ b/httpkit/httpclient/proxy.go @@ -0,0 +1,142 @@ +package httpclient + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +// buildProxyFunc returns the function suitable for http.Transport.Proxy, +// selected by cfg.Mode. A nil, nil return means "no proxy". +func buildProxyFunc(cfg ProxyConfig) (func(*http.Request) (*url.URL, error), error) { + switch cfg.Mode { + case "", "none": + return nil, nil + case "environment": + return http.ProxyFromEnvironment, nil + case "url": + return buildExplicitProxyFunc(cfg) + default: + return nil, fmt.Errorf("httpclient: unknown Proxy.Mode %q (expected \"none\", \"environment\", or \"url\")", cfg.Mode) + } +} + +func buildExplicitProxyFunc(cfg ProxyConfig) (func(*http.Request) (*url.URL, error), error) { + if cfg.URL == "" { + return nil, fmt.Errorf("httpclient: Proxy.Mode \"url\" requires Proxy.URL to be set") + } + proxyURL, err := url.Parse(cfg.URL) + if err != nil { + return nil, fmt.Errorf("httpclient: invalid Proxy.URL: %w", err) + } + if cfg.Username != "" { + proxyURL.User = url.UserPassword(cfg.Username, cfg.Password) + } + + bypass, err := newNoProxyMatcher(cfg.NoProxy) + if err != nil { + return nil, err + } + + return func(req *http.Request) (*url.URL, error) { + if bypass(req.URL.Hostname()) { + return nil, nil + } + return proxyURL, nil + }, nil +} + +// newNoProxyMatcher builds a matcher for a NoProxy bypass list. Each entry +// is either a CIDR, a ".suffix" domain match, or an exact hostname match +// (case-insensitive). +func newNoProxyMatcher(entries []string) (func(host string) bool, error) { + exact := make(map[string]bool) + var suffixes []string + var cidrs []*net.IPNet + + for _, e := range entries { + e = strings.TrimSpace(e) + if e == "" { + continue + } + if _, n, err := net.ParseCIDR(e); err == nil { + cidrs = append(cidrs, n) + continue + } + if strings.HasPrefix(e, ".") { + suffixes = append(suffixes, strings.ToLower(e)) + continue + } + exact[strings.ToLower(e)] = true + } + + return func(host string) bool { + host = strings.ToLower(host) + if exact[host] { + return true + } + for _, s := range suffixes { + if strings.HasSuffix(host, s) || host == strings.TrimPrefix(s, ".") { + return true + } + } + if ip := net.ParseIP(host); ip != nil { + for _, c := range cidrs { + if c.Contains(ip) { + return true + } + } + } + return false + }, nil +} + +// dialProxyTLS returns a DialTLSContext function that hand-terminates the +// PROXY-facing TLS handshake using proxyTLS (Tier 2: the proxy itself needs +// its own client certificate, distinct from the origin's). +// +// http.Transport routes both the plain-proxy and https-proxy first-hop +// dial through DialContext/DialTLSContext with addr set to the PROXY's +// address (connectMethod.addr() returns the proxy's address whenever +// Transport.Proxy is set) — never the origin's, whether or not a CONNECT +// tunnel follows. This is why it is safe for dialProxyTLS to assume addr +// here is always the proxy, and why it must not attempt to apply origin +// policy at this layer; Transport performs its own second, nested TLS +// handshake for the origin afterwards using Transport.TLSClientConfig. +// +// This behavior is exercised by transport_connect_test.go against this +// module's pinned Go toolchain version; re-verify on every Go upgrade, since +// DialTLSContext's doc comment describes it more narrowly ("non-proxied +// HTTPS requests") than what net/http's dialConn gating actually does today. +func dialProxyTLS(dialFn func(context.Context, string, string) (net.Conn, error), proxyTLS *tls.Config, handshakeTimeout time.Duration) func(ctx context.Context, network, addr string) (net.Conn, error) { + return func(ctx context.Context, network, addr string) (net.Conn, error) { + raw, err := dialFn(ctx, network, addr) + if err != nil { + return nil, err + } + host, _, splitErr := net.SplitHostPort(addr) + if splitErr != nil { + raw.Close() + return nil, fmt.Errorf("httpclient: invalid proxy address") + } + + hctx := ctx + var cancel context.CancelFunc + if handshakeTimeout > 0 { + hctx, cancel = context.WithTimeout(ctx, handshakeTimeout) + defer cancel() + } + + tlsConn := tls.Client(raw, cloneTLSConfigForHost(proxyTLS, host)) + if err := tlsConn.HandshakeContext(hctx); err != nil { + raw.Close() + return nil, fmt.Errorf("httpclient: proxy TLS handshake failed") + } + return tlsConn, nil + } +} diff --git a/httpkit/httpclient/proxy_test.go b/httpkit/httpclient/proxy_test.go new file mode 100644 index 0000000000..17632a390b --- /dev/null +++ b/httpkit/httpclient/proxy_test.go @@ -0,0 +1,116 @@ +package httpclient + +import ( + "net/http" + "testing" +) + +func TestNewNoProxyMatcher(t *testing.T) { + match, err := newNoProxyMatcher([]string{"internal.svc", ".corp.example.com", "10.0.0.0/8"}) + if err != nil { + t.Fatalf("newNoProxyMatcher: %v", err) + } + + tests := []struct { + host string + want bool + }{ + {"internal.svc", true}, + {"INTERNAL.SVC", true}, // case-insensitive + {"other.svc", false}, + {"api.corp.example.com", true}, + {"corp.example.com", true}, // exact match on the suffix's own domain + {"example.com", false}, + {"10.1.2.3", true}, + {"11.1.2.3", false}, + {"8.8.8.8", false}, + } + for _, tt := range tests { + if got := match(tt.host); got != tt.want { + t.Errorf("match(%q) = %v, want %v", tt.host, got, tt.want) + } + } +} + +func TestBuildProxyFunc_Modes(t *testing.T) { + t.Run("none returns nil func", func(t *testing.T) { + fn, err := buildProxyFunc(ProxyConfig{Mode: "none"}) + if err != nil || fn != nil { + t.Fatalf("buildProxyFunc(none): fn != nil = %v, err = %v, want nil, nil", fn != nil, err) + } + }) + + t.Run("empty mode defaults to none", func(t *testing.T) { + fn, err := buildProxyFunc(ProxyConfig{}) + if err != nil || fn != nil { + t.Fatalf("buildProxyFunc({}): fn != nil = %v, err = %v, want nil, nil", fn != nil, err) + } + }) + + t.Run("environment returns a func", func(t *testing.T) { + fn, err := buildProxyFunc(ProxyConfig{Mode: "environment"}) + if err != nil || fn == nil { + t.Fatalf("buildProxyFunc(environment): fn != nil = %v, err = %v, want true, nil", fn != nil, err) + } + }) + + t.Run("url mode resolves to the configured proxy", func(t *testing.T) { + fn, err := buildProxyFunc(ProxyConfig{Mode: "url", URL: "http://proxy.example:3128"}) + if err != nil { + t.Fatalf("buildProxyFunc(url): %v", err) + } + req, _ := http.NewRequest(http.MethodGet, "https://target.example/", nil) + got, err := fn(req) + if err != nil { + t.Fatalf("proxy func: %v", err) + } + if got == nil || got.Host != "proxy.example:3128" { + t.Fatalf("proxy func returned %v, want proxy.example:3128", got) + } + }) + + t.Run("url mode honors NoProxy bypass", func(t *testing.T) { + fn, err := buildProxyFunc(ProxyConfig{Mode: "url", URL: "http://proxy.example:3128", NoProxy: []string{"target.example"}}) + if err != nil { + t.Fatalf("buildProxyFunc(url): %v", err) + } + req, _ := http.NewRequest(http.MethodGet, "https://target.example/", nil) + got, err := fn(req) + if err != nil { + t.Fatalf("proxy func: %v", err) + } + if got != nil { + t.Fatalf("expected NoProxy bypass to return nil proxy, got %v", got) + } + }) + + t.Run("url mode sets basic auth from Username/Password", func(t *testing.T) { + fn, err := buildProxyFunc(ProxyConfig{Mode: "url", URL: "http://proxy.example:3128", Username: "u", Password: "p"}) + if err != nil { + t.Fatalf("buildProxyFunc(url): %v", err) + } + req, _ := http.NewRequest(http.MethodGet, "https://target.example/", nil) + got, err := fn(req) + if err != nil { + t.Fatalf("proxy func: %v", err) + } + if got.User == nil { + t.Fatal("expected proxy URL to carry basic-auth userinfo") + } + if user := got.User.Username(); user != "u" { + t.Fatalf("proxy user = %q, want %q", user, "u") + } + }) + + t.Run("unknown mode is rejected", func(t *testing.T) { + if _, err := buildProxyFunc(ProxyConfig{Mode: "socks5"}); err == nil { + t.Fatal("expected an error for an unknown proxy mode") + } + }) + + t.Run("url mode without URL is rejected", func(t *testing.T) { + if _, err := buildProxyFunc(ProxyConfig{Mode: "url"}); err == nil { + t.Fatal("expected an error when Proxy.Mode is url but Proxy.URL is empty") + } + }) +} diff --git a/httpkit/httpclient/roundtrip_connect.go b/httpkit/httpclient/roundtrip_connect.go new file mode 100644 index 0000000000..c42c21e40c --- /dev/null +++ b/httpkit/httpclient/roundtrip_connect.go @@ -0,0 +1,215 @@ +package httpclient + +import ( + "bufio" + "context" + "crypto/tls" + "encoding/base64" + "fmt" + "io" + "net" + "net/http" + "net/url" + "time" + + "github.com/wso2/go-httpkit/netguard" +) + +// connectRoundTripper implements http.RoundTripper by hand for +// ProxyEgressManualCONNECT. It validates the origin hostname locally +// against the configured SSRF policy BEFORE ever issuing a CONNECT request +// or writing any bytes intended for the origin, then performs the proxy +// dial, CONNECT handshake, and (for an https target) the TLS handshake over +// the resulting tunnel itself. +// +// This intentionally bypasses http.Transport's connection pooling — each +// RoundTrip dials a fresh connection to the proxy. That's an accepted +// tradeoff for this advanced, comparatively rare mode; the common case +// (ProxyEgressDelegated, or no SSRF guard at all) uses the pooling +// *http.Transport from transport.go instead. +type connectRoundTripper struct { + proxyURL *url.URL + dialProxy func(ctx context.Context, network, addr string) (net.Conn, error) + originPolicy netguard.Policy + originTLS *tls.Config + connectHeader func(ctx context.Context, proxyURL *url.URL, target string) (http.Header, error) + tlsHandshakeTimeout time.Duration +} + +// newConnectRoundTripper builds the RoundTripper used for +// ProxyEgressManualCONNECT. It requires an explicit Proxy.URL (Mode +// "environment" resolves per-request and doesn't fit this mode's +// dial-before-you-know-the-target-is-safe model) and requires SSRF.Enabled, +// since this mode exists specifically to validate the origin against +// SSRF.Policy before it's ever handed to the proxy. +func newConnectRoundTripper(cfg Config, dialFn func(context.Context, string, string) (net.Conn, error), originTLS *tls.Config) (http.RoundTripper, error) { + if cfg.Proxy.Mode != "url" { + return nil, fmt.Errorf("httpclient: Proxy.Egress == ProxyEgressManualCONNECT requires Proxy.Mode \"url\" (an explicit, fixed proxy target)") + } + if cfg.Proxy.URL == "" { + return nil, fmt.Errorf("httpclient: Proxy.Mode \"url\" requires Proxy.URL to be set") + } + if !cfg.SSRF.Enabled { + return nil, fmt.Errorf("httpclient: Proxy.Egress == ProxyEgressManualCONNECT requires SSRF.Enabled (this mode exists to validate the origin against SSRF.Policy)") + } + + proxyURL, err := url.Parse(cfg.Proxy.URL) + if err != nil { + return nil, fmt.Errorf("httpclient: invalid Proxy.URL: %w", err) + } + if cfg.Proxy.Username != "" { + proxyURL.User = url.UserPassword(cfg.Proxy.Username, cfg.Proxy.Password) + } + + return &connectRoundTripper{ + proxyURL: proxyURL, + dialProxy: dialFn, + originPolicy: cfg.SSRF.Policy, + originTLS: originTLS, + connectHeader: cfg.Proxy.ConnectHeader, + tlsHandshakeTimeout: cfg.Timeouts.TLSHandshake, + }, nil +} + +func (c *connectRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + ctx := req.Context() + + // Validate the origin BEFORE any network activity aimed at it — this is + // the entire point of this mode. A generic error is returned; the + // resolved address/reason is not, so as not to leak internal topology. + if err := netguard.Validate(ctx, c.originPolicy, req.URL.Hostname()); err != nil { + return nil, fmt.Errorf("httpclient: origin destination rejected") + } + + conn, err := c.dialProxy(ctx, "tcp", canonicalAddr(c.proxyURL)) + if err != nil { + return nil, fmt.Errorf("httpclient: failed to connect to proxy") + } + ok := false + defer func() { + if !ok { + conn.Close() + } + }() + + if c.proxyURL.Scheme == "https" { + conn, err = c.handshake(ctx, conn, c.proxyURL.Hostname(), &tls.Config{}) + if err != nil { + return nil, fmt.Errorf("httpclient: proxy TLS handshake failed") + } + } + + target := canonicalAddr(req.URL) + if req.URL.Scheme == "https" { + if err := c.writeConnect(ctx, conn, target); err != nil { + return nil, err + } + conn, err = c.handshake(ctx, conn, req.URL.Hostname(), c.originTLS) + if err != nil { + return nil, fmt.Errorf("httpclient: origin TLS handshake failed") + } + } + + if err := req.Write(conn); err != nil { + return nil, fmt.Errorf("httpclient: failed to write request") + } + + resp, err := http.ReadResponse(bufio.NewReader(conn), req) + if err != nil { + return nil, fmt.Errorf("httpclient: failed to read response") + } + ok = true // resp.Body now owns closing conn + resp.Body = &connOwningBody{ReadCloser: resp.Body, conn: conn} + return resp, nil +} + +// handshake wraps conn in a TLS client connection for host, using +// cloneTLSConfigForHost so ServerName always comes from the intended +// hostname rather than the dialed address. +func (c *connectRoundTripper) handshake(ctx context.Context, conn net.Conn, host string, base *tls.Config) (net.Conn, error) { + hctx := ctx + if c.tlsHandshakeTimeout > 0 { + var cancel context.CancelFunc + hctx, cancel = context.WithTimeout(ctx, c.tlsHandshakeTimeout) + defer cancel() + } + tlsConn := tls.Client(conn, cloneTLSConfigForHost(base, host)) + if err := tlsConn.HandshakeContext(hctx); err != nil { + return nil, err + } + return tlsConn, nil +} + +// writeConnect issues a CONNECT request for target over conn and consumes +// the proxy's response, returning an error unless it reports success. +func (c *connectRoundTripper) writeConnect(ctx context.Context, conn net.Conn, target string) error { + header := make(http.Header) + if c.connectHeader != nil { + h, err := c.connectHeader(ctx, c.proxyURL, target) + if err != nil { + return fmt.Errorf("httpclient: failed to build CONNECT headers") + } + if h != nil { + header = h + } + } + if u := c.proxyURL.User; u != nil { + password, _ := u.Password() + header.Set("Proxy-Authorization", basicAuth(u.Username(), password)) + } + + connectReq := &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Opaque: target}, + Host: target, + Header: header, + } + if err := connectReq.Write(conn); err != nil { + return fmt.Errorf("httpclient: failed to write CONNECT request") + } + + resp, err := http.ReadResponse(bufio.NewReader(conn), connectReq) + if err != nil { + return fmt.Errorf("httpclient: failed to read CONNECT response") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("httpclient: proxy refused CONNECT") + } + return nil +} + +// canonicalAddr returns u's host:port, defaulting the port from the scheme +// when absent. +func canonicalAddr(u *url.URL) string { + if u.Port() != "" { + return u.Host + } + port := "80" + if u.Scheme == "https" { + port = "443" + } + return net.JoinHostPort(u.Hostname(), port) +} + +func basicAuth(username, password string) string { + auth := username + ":" + password + return "Basic " + base64.StdEncoding.EncodeToString([]byte(auth)) +} + +// connOwningBody closes the underlying connection when the response body is +// closed — http.ReadResponse's own Body, built over a bufio.Reader wrapping +// a raw net.Conn, does not take ownership of closing that conn itself. +type connOwningBody struct { + io.ReadCloser + conn net.Conn +} + +func (b *connOwningBody) Close() error { + bodyErr := b.ReadCloser.Close() + connErr := b.conn.Close() + if bodyErr != nil { + return bodyErr + } + return connErr +} diff --git a/httpkit/httpclient/roundtrip_connect_test.go b/httpkit/httpclient/roundtrip_connect_test.go new file mode 100644 index 0000000000..86ef8c5dd7 --- /dev/null +++ b/httpkit/httpclient/roundtrip_connect_test.go @@ -0,0 +1,118 @@ +package httpclient + +import ( + "crypto/tls" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/wso2/go-httpkit/netguard" +) + +// TestNew_ManualCONNECT_Succeeds proves the ProxyEgressManualCONNECT path +// actually completes a real request through a plain CONNECT proxy once +// local SSRF validation passes. +func TestNew_ManualCONNECT_Succeeds(t *testing.T) { + cert := newSelfSignedCert(t, "manual-connect-origin", nil, []net.IP{net.ParseIP("127.0.0.1")}) + + origin := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + origin.TLS = &tls.Config{Certificates: []tls.Certificate{cert.tlsCert}} + origin.StartTLS() + defer origin.Close() + + proxyAddr := startConnectProxy(t) + + cfg := DefaultConfig() + cfg.TLS.RootCAs = cert.pool + cfg.Proxy.Mode = "url" + cfg.Proxy.URL = "http://" + proxyAddr + cfg.Proxy.Egress = ProxyEgressManualCONNECT + cfg.SSRF.Enabled = true + cfg.SSRF.Policy = netguard.PermitPrivateBlockMetadata() // permits loopback + + client, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + + resp, err := client.Get(origin.URL) + if err != nil { + t.Fatalf("client.Get via ProxyEgressManualCONNECT: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +// TestNew_ManualCONNECT_RejectsDisallowedOrigin proves local origin +// validation actually runs and blocks the request BEFORE any CONNECT is +// attempted, when the origin fails the configured SSRF policy. +func TestNew_ManualCONNECT_RejectsDisallowedOrigin(t *testing.T) { + cert := newSelfSignedCert(t, "manual-connect-origin2", nil, []net.IP{net.ParseIP("127.0.0.1")}) + + origin := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + origin.TLS = &tls.Config{Certificates: []tls.Certificate{cert.tlsCert}} + origin.StartTLS() + defer origin.Close() + + proxyAddr := startConnectProxy(t) + + cfg := DefaultConfig() + cfg.TLS.RootCAs = cert.pool + cfg.Proxy.Mode = "url" + cfg.Proxy.URL = "http://" + proxyAddr + cfg.Proxy.Egress = ProxyEgressManualCONNECT + cfg.SSRF.Enabled = true + cfg.SSRF.Policy = netguard.PublicOnly() // blocks loopback -- origin must be rejected + + client, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + + if _, err := client.Get(origin.URL); err == nil { + t.Fatal("expected a request to a loopback origin to be rejected under PublicOnly policy") + } +} + +func TestNewConnectRoundTripper_ValidatesConfig(t *testing.T) { + baseTLS, err := buildTLSConfig(TLSConfig{}) + if err != nil { + t.Fatalf("buildTLSConfig: %v", err) + } + + t.Run("requires Mode url", func(t *testing.T) { + cfg := DefaultConfig() + cfg.Proxy.Mode = "environment" + cfg.SSRF.Enabled = true + cfg.SSRF.Policy = netguard.PermitPrivateBlockMetadata() + if _, err := newConnectRoundTripper(cfg, nil, baseTLS); err == nil { + t.Fatal("expected an error when Proxy.Mode is not \"url\"") + } + }) + + t.Run("requires SSRF enabled", func(t *testing.T) { + cfg := DefaultConfig() + cfg.Proxy.Mode = "url" + cfg.Proxy.URL = "http://proxy.example:3128" + if _, err := newConnectRoundTripper(cfg, nil, baseTLS); err == nil { + t.Fatal("expected an error when SSRF.Enabled is false") + } + }) + + t.Run("requires Proxy.URL", func(t *testing.T) { + cfg := DefaultConfig() + cfg.Proxy.Mode = "url" + cfg.SSRF.Enabled = true + cfg.SSRF.Policy = netguard.PermitPrivateBlockMetadata() + if _, err := newConnectRoundTripper(cfg, nil, baseTLS); err == nil { + t.Fatal("expected an error when Proxy.URL is empty") + } + }) +} diff --git a/httpkit/httpclient/tls.go b/httpkit/httpclient/tls.go new file mode 100644 index 0000000000..38455f1081 --- /dev/null +++ b/httpkit/httpclient/tls.go @@ -0,0 +1,160 @@ +package httpclient + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" + + "github.com/wso2/go-httpkit/tlsconfig" +) + +// buildTLSConfig builds the *tls.Config used for the ORIGIN handshake +// (whether dialed directly or reached through a CONNECT tunnel). It never +// sets ServerName: Go's own TLS dialing fills that in per-target only when +// it is left empty (see net/http's addTLS), and a client built by this +// package is expected to be reused across many hosts — a fixed ServerName +// here would silently misverify every host but the first. +func buildTLSConfig(cfg TLSConfig) (*tls.Config, error) { + if err := tlsconfig.ValidateVersionRange(cfg.MinVersion, cfg.MaxVersion); err != nil { + return nil, fmt.Errorf("httpclient: %w", err) + } + if err := validateVerificationConfig(cfg.InsecureSkipVerify, cfg.InsecureSkipVerifyAcknowledged, cfg.VerifyPeerCertificate != nil || cfg.VerifyConnection != nil); err != nil { + return nil, err + } + + curves, err := tlsconfig.ParseCurvePreferences(cfg.CurvePreferences) + if err != nil { + return nil, fmt.Errorf("httpclient: %w", err) + } + ciphers, err := tlsconfig.ParseCipherSuites(cfg.CipherSuites) + if err != nil { + return nil, fmt.Errorf("httpclient: %w", err) + } + + tlsCfg := &tls.Config{ + CurvePreferences: curves, + CipherSuites: ciphers, + InsecureSkipVerify: cfg.InsecureSkipVerify, + VerifyPeerCertificate: cfg.VerifyPeerCertificate, + VerifyConnection: cfg.VerifyConnection, + // ServerName intentionally left unset — see the doc comment above. + } + + if cfg.MinVersion != "" { + v, _ := tlsconfig.ParseVersion(cfg.MinVersion) + tlsCfg.MinVersion = v + } + if cfg.MaxVersion != "" { + v, _ := tlsconfig.ParseVersion(cfg.MaxVersion) + tlsCfg.MaxVersion = v + } + + if cfg.RootCAs != nil { + tlsCfg.RootCAs = cfg.RootCAs + } else if cfg.RootCAFile != "" { + pool, err := loadCertPool(cfg.RootCAFile) + if err != nil { + return nil, err + } + tlsCfg.RootCAs = pool + } + + switch { + case cfg.GetClientCertificate != nil: + tlsCfg.GetClientCertificate = cfg.GetClientCertificate + case cfg.ClientCertFile != "" || cfg.ClientKeyFile != "": + if cfg.ClientCertFile == "" || cfg.ClientKeyFile == "" { + return nil, fmt.Errorf("httpclient: TLS.ClientCertFile and TLS.ClientKeyFile must both be set for mTLS") + } + cert, err := tls.LoadX509KeyPair(cfg.ClientCertFile, cfg.ClientKeyFile) + if err != nil { + return nil, fmt.Errorf("httpclient: failed to load client certificate") + } + tlsCfg.Certificates = []tls.Certificate{cert} + } + + return tlsCfg, nil +} + +// buildProxyTLSConfig builds the *tls.Config used for the SEPARATE, +// proxy-facing TLS handshake (Tier 2: the proxy itself requires its own +// client certificate, distinct from the origin's). +func buildProxyTLSConfig(cfg ProxyTLSConfig) (*tls.Config, error) { + if err := validateVerificationConfig(cfg.InsecureSkipVerify, cfg.InsecureSkipVerifyAcknowledged, false); err != nil { + return nil, err + } + + tlsCfg := &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify} + + if cfg.RootCAs != nil { + tlsCfg.RootCAs = cfg.RootCAs + } else if cfg.RootCAFile != "" { + pool, err := loadCertPool(cfg.RootCAFile) + if err != nil { + return nil, err + } + tlsCfg.RootCAs = pool + } + + switch { + case cfg.GetClientCertificate != nil: + tlsCfg.GetClientCertificate = cfg.GetClientCertificate + case cfg.ClientCertFile != "" || cfg.ClientKeyFile != "": + if cfg.ClientCertFile == "" || cfg.ClientKeyFile == "" { + return nil, fmt.Errorf("httpclient: Proxy.ProxyTLS.ClientCertFile and ClientKeyFile must both be set for proxy mTLS") + } + cert, err := tls.LoadX509KeyPair(cfg.ClientCertFile, cfg.ClientKeyFile) + if err != nil { + return nil, fmt.Errorf("httpclient: failed to load proxy client certificate") + } + tlsCfg.Certificates = []tls.Certificate{cert} + } + + return tlsCfg, nil +} + +// validateVerificationConfig enforces the hostname-verification invariants +// shared by both TLSConfig and ProxyTLSConfig: InsecureSkipVerify requires +// explicit acknowledgement, and can never be combined with a custom verify +// callback (which would then silently become the only check performed, +// since its verifiedChains argument is empty when default verification +// didn't run). +func validateVerificationConfig(insecureSkipVerify, acknowledged, hasCustomVerify bool) error { + if !insecureSkipVerify { + return nil + } + if !acknowledged { + return fmt.Errorf("httpclient: InsecureSkipVerify requires InsecureSkipVerifyAcknowledged to also be set") + } + if hasCustomVerify { + return fmt.Errorf("httpclient: InsecureSkipVerify cannot be combined with VerifyPeerCertificate/VerifyConnection — a custom callback would become the only check performed") + } + return nil +} + +// loadCertPool reads a PEM-encoded CA bundle from disk into an +// *x509.CertPool. +func loadCertPool(path string) (*x509.CertPool, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("httpclient: failed to read CA bundle") + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(data) { + return nil, fmt.Errorf("httpclient: CA bundle contains no usable certificates") + } + return pool, nil +} + +// cloneTLSConfigForHost returns a shallow clone of base with ServerName set +// to host. Every hand-rolled tls.Client/Handshake call in this package +// (the Tier-2 proxy dialer, the manual-CONNECT round tripper) must route +// through this helper rather than setting ServerName ad hoc or leaving it +// empty — the intended hostname must always come from the original request, +// never from a dialed IP address. +func cloneTLSConfigForHost(base *tls.Config, host string) *tls.Config { + cfg := base.Clone() + cfg.ServerName = host + return cfg +} diff --git a/httpkit/httpclient/tls_test.go b/httpkit/httpclient/tls_test.go new file mode 100644 index 0000000000..dd6b95d543 --- /dev/null +++ b/httpkit/httpclient/tls_test.go @@ -0,0 +1,169 @@ +package httpclient + +import ( + "crypto/tls" + "crypto/x509" + "net" + "net/http" + "net/http/httptest" + "testing" +) + +// startTLSServerOn starts an httptest TLS server bound to bindIP (instead of +// httptest's default 127.0.0.1) presenting cert, so two servers in the same +// test can have genuinely distinct identities (distinct IP-literal SANs) +// without needing DNS or /etc/hosts. +func startTLSServerOn(t *testing.T, bindIP string, cert tls.Certificate, handler http.Handler) *httptest.Server { + t.Helper() + srv := httptest.NewUnstartedServer(handler) + if err := srv.Listener.Close(); err != nil { + t.Fatalf("closing default listener: %v", err) + } + ln, err := net.Listen("tcp", bindIP+":0") + if err != nil { + t.Skipf("cannot bind %s (loopback range may be restricted in this sandbox): %v", bindIP, err) + } + srv.Listener = ln + srv.TLS = &tls.Config{Certificates: []tls.Certificate{cert}} + srv.StartTLS() + return srv +} + +// TestNew_NoStaleServerNameAcrossHosts proves a single long-lived client +// built by New correctly verifies two different TLS servers with two +// different certificates and identities — regression coverage for the +// requirement that Config never carries a fixed tls.Config.ServerName +// (which would silently misverify every host but the first one dialed). The +// two servers are given distinct loopback IPs (127.0.0.1 / 127.0.0.2) so +// their identities genuinely differ without relying on DNS. +func TestNew_NoStaleServerNameAcrossHosts(t *testing.T) { + certA := newSelfSignedCert(t, "host-a", nil, []net.IP{net.ParseIP("127.0.0.1")}) + certB := newSelfSignedCert(t, "host-b", nil, []net.IP{net.ParseIP("127.0.0.2")}) + + pool := x509.NewCertPool() + pool.AddCert(certA.leaf) + pool.AddCert(certB.leaf) + + srvA := startTLSServerOn(t, "127.0.0.1", certA.tlsCert, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("A")) + })) + defer srvA.Close() + + srvB := startTLSServerOn(t, "127.0.0.2", certB.tlsCert, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("B")) + })) + defer srvB.Close() + + cfg := DefaultConfig() + cfg.TLS.RootCAs = pool + client, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + + respA, err := client.Get(srvA.URL) + if err != nil { + t.Fatalf("client.Get(A): %v", err) + } + respA.Body.Close() + if respA.StatusCode != http.StatusOK { + t.Fatalf("A status = %d", respA.StatusCode) + } + + // If a fixed ServerName ("127.0.0.1") leaked from the first request, + // this second request — to a server whose cert only covers 127.0.0.2 — + // would fail hostname verification. + respB, err := client.Get(srvB.URL) + if err != nil { + t.Fatalf("client.Get(B) — this fails if ServerName leaked from the A request: %v", err) + } + respB.Body.Close() + if respB.StatusCode != http.StatusOK { + t.Fatalf("B status = %d", respB.StatusCode) + } +} + +// TestNew_RejectsWrongHostCertificate proves default hostname verification +// is intact (not silently disabled) — a client trusting certA's issuer must +// still refuse a connection presenting a cert for a different identity. +func TestNew_RejectsWrongHostCertificate(t *testing.T) { + certForOther := newSelfSignedCert(t, "other", nil, []net.IP{net.ParseIP("127.0.0.3")}) + certPresented := newSelfSignedCert(t, "presented", nil, []net.IP{net.ParseIP("127.0.0.1")}) + + // The client only trusts certForOther, but the server presents + // certPresented — verification must fail. + pool := x509.NewCertPool() + pool.AddCert(certForOther.leaf) + + srv := startTLSServerOn(t, "127.0.0.1", certPresented.tlsCert, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + cfg := DefaultConfig() + cfg.TLS.RootCAs = pool + client, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + + if _, err := client.Get(srv.URL); err == nil { + t.Fatal("expected a request against an untrusted certificate to fail") + } +} + +// TestNew_MTLSToOrigin proves the common mTLS-to-origin wiring (TLSConfig's +// ClientCertFile/Key, or here the in-memory Certificates equivalent via +// GetClientCertificate) actually presents a client certificate the server +// can verify, and that the server correctly sees the expected certificate. +func TestNew_MTLSToOrigin(t *testing.T) { + serverCert := newSelfSignedCert(t, "origin-server", nil, []net.IP{net.ParseIP("127.0.0.1")}) + clientCert := newSelfSignedCert(t, "httpkit-test", []string{"test-client"}, nil) + + clientCAs := x509.NewCertPool() + clientCAs.AddCert(clientCert.leaf) + + var sawClientCN string + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(r.TLS.PeerCertificates) > 0 { + sawClientCN = r.TLS.PeerCertificates[0].Subject.CommonName + } + w.WriteHeader(http.StatusOK) + })) + srv.TLS = &tls.Config{ + Certificates: []tls.Certificate{serverCert.tlsCert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: clientCAs, + } + srv.StartTLS() + defer srv.Close() + + cfg := DefaultConfig() + cfg.TLS.RootCAs = serverCert.pool + cfg.TLS.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { + return &clientCert.tlsCert, nil + } + client, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("client.Get: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + if sawClientCN != "httpkit-test" { + t.Fatalf("server saw client CN %q, want %q", sawClientCN, "httpkit-test") + } +} + +func TestBuildTLSConfig_RejectsAsymmetricVersionRange(t *testing.T) { + _, err := buildTLSConfig(TLSConfig{MinVersion: "TLS1_3", MaxVersion: "TLS1_2"}) + if err == nil { + t.Fatal("expected an error for min_version > max_version") + } +} diff --git a/httpkit/httpclient/transport.go b/httpkit/httpclient/transport.go new file mode 100644 index 0000000000..13118c10b9 --- /dev/null +++ b/httpkit/httpclient/transport.go @@ -0,0 +1,108 @@ +package httpclient + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "net/url" +) + +// buildTransport builds the *http.Transport used for every ProxyEgress +// mode except ProxyEgressManualCONNECT (which uses a hand-rolled +// RoundTripper instead — see roundtrip_connect.go). +// +// dialFn is always wired as Transport.DialContext, regardless of whether a +// proxy is configured. This is what makes the "guard only validates the +// proxy hop when proxying" behavior (ProxyEgressDelegated) automatic rather +// than a special case: net/http's Transport always dials +// connectMethod.addr(), which is the proxy's address whenever Proxy is set +// and the origin's address otherwise — dialFn sees whichever one Transport +// asks for and applies the same policy either way. +func buildTransport(cfg Config, dialFn func(context.Context, string, string) (net.Conn, error), originTLS, proxyTLS *tls.Config) (*http.Transport, error) { + proxyFunc, err := buildProxyFunc(cfg.Proxy) + if err != nil { + return nil, err + } + + t := &http.Transport{ + Proxy: proxyFunc, + DialContext: dialFn, + TLSClientConfig: originTLS, + MaxIdleConns: cfg.Pooling.MaxIdleConns, + MaxIdleConnsPerHost: cfg.Pooling.MaxIdleConnsPerHost, + MaxConnsPerHost: cfg.Pooling.MaxConnsPerHost, + IdleConnTimeout: cfg.Pooling.IdleConnTimeout, + DisableKeepAlives: cfg.Pooling.DisableKeepAlives, + TLSHandshakeTimeout: cfg.Timeouts.TLSHandshake, + ResponseHeaderTimeout: cfg.Timeouts.ResponseHeader, + ExpectContinueTimeout: cfg.Timeouts.ExpectContinue, + // ForceAttemptHTTP2 defaults to false: Go's Transport already + // disables HTTP/2 conservatively whenever a custom DialContext or + // TLSClientConfig is set (both always true here) unless this is + // explicitly requested — see PoolingConfig.EnableHTTP2's doc for the + // connection-coalescing tradeoff this opt-in carries. + ForceAttemptHTTP2: cfg.Pooling.EnableHTTP2, + } + + if proxyTLS != nil { + if proxyFunc == nil { + return nil, fmt.Errorf("httpclient: Proxy.ProxyTLS is set but Proxy.Mode is \"none\" — a proxy must be configured to have its own TLS settings") + } + t.DialTLSContext = dialProxyTLS(dialFn, proxyTLS, cfg.Timeouts.TLSHandshake) + } + + if cfg.Proxy.ConnectHeader != nil { + connectHeader := cfg.Proxy.ConnectHeader + t.GetProxyConnectHeader = func(ctx context.Context, proxyURL *url.URL, target string) (http.Header, error) { + return connectHeader(ctx, proxyURL, target) + } + } + + return t, nil +} + +// maxBytesRoundTripper wraps a RoundTripper so that reading a response body +// past a configured byte ceiling returns an error instead of continuing +// unbounded, per go-network-service-hardening.md's requirement that every +// inbound reader is bounded. +type maxBytesRoundTripper struct { + next http.RoundTripper + max int64 +} + +func (m *maxBytesRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := m.next.RoundTrip(req) + if err != nil || resp == nil || resp.Body == nil { + return resp, err + } + resp.Body = &maxBytesReadCloser{body: resp.Body, max: m.max} + return resp, nil +} + +// maxBytesReadCloser bounds how many bytes may be read from body. Reading +// within the limit behaves exactly like the underlying reader (including a +// natural io.EOF for a body that ends at or before the limit); only once +// more than max bytes have actually been read does it return an error, so a +// body of precisely max bytes is never misreported as having exceeded the +// limit. +type maxBytesReadCloser struct { + body io.ReadCloser + max int64 + read int64 +} + +func (m *maxBytesReadCloser) Read(p []byte) (int, error) { + n, err := m.body.Read(p) + m.read += int64(n) + if m.read > m.max { + return n, fmt.Errorf("httpclient: response body exceeds the configured maximum size (%d bytes)", m.max) + } + return n, err +} + +func (m *maxBytesReadCloser) Close() error { + return m.body.Close() +} diff --git a/httpkit/httpclient/transport_connect_test.go b/httpkit/httpclient/transport_connect_test.go new file mode 100644 index 0000000000..1b43712e87 --- /dev/null +++ b/httpkit/httpclient/transport_connect_test.go @@ -0,0 +1,196 @@ +package httpclient + +import ( + "bufio" + "crypto/tls" + "io" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/wso2/go-httpkit/tlsconfig" +) + +// startConnectProxy starts a minimal CONNECT-speaking forward proxy: it +// reads a CONNECT request, dials the requested target, replies 200, and +// splices bytes bidirectionally. It performs no TLS itself and no +// validation — it exists only to exercise this package's proxy+mTLS wiring +// against a real CONNECT tunnel. +func startConnectProxy(t *testing.T) (addr string) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleConnectConn(conn) + } + }() + return ln.Addr().String() +} + +func handleConnectConn(conn net.Conn) { + defer conn.Close() + br := bufio.NewReader(conn) + req, err := http.ReadRequest(br) + if err != nil || req.Method != http.MethodConnect { + return + } + + target, err := net.Dial("tcp", req.Host) + if err != nil { + conn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) + return + } + defer target.Close() + + if _, err := conn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil { + return + } + + done := make(chan struct{}, 2) + go func() { io.Copy(target, br); done <- struct{}{} }() + go func() { io.Copy(conn, target); done <- struct{}{} }() + <-done +} + +// TestNew_MTLSThroughCONNECTProxy proves the "common case" wiring from the +// design (Tier 1): a plain-HTTP forward proxy tunneling an mTLS connection +// to the origin, using only Transport.Proxy + Transport.TLSClientConfig — +// no custom DialTLSContext needed. This is the shape most real deployments +// (mTLS to a backend through a corporate/K8s egress proxy) will actually +// use. +func TestNew_MTLSThroughCONNECTProxy(t *testing.T) { + serverCert := newSelfSignedCert(t, "origin-server", nil, []net.IP{net.ParseIP("127.0.0.1")}) + clientCert := newSelfSignedCert(t, "httpkit-test", []string{"tunnel-client"}, nil) + + clientCAs := clientCert.pool + + var sawClientCN string + origin := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(r.TLS.PeerCertificates) > 0 { + sawClientCN = r.TLS.PeerCertificates[0].Subject.CommonName + } + w.WriteHeader(http.StatusOK) + })) + origin.TLS = &tls.Config{ + Certificates: []tls.Certificate{serverCert.tlsCert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: clientCAs, + } + origin.StartTLS() + defer origin.Close() + + proxyAddr := startConnectProxy(t) + + cfg := DefaultConfig() + cfg.TLS.RootCAs = serverCert.pool + cfg.TLS.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { + return &clientCert.tlsCert, nil + } + cfg.Proxy.Mode = "url" + cfg.Proxy.URL = "http://" + proxyAddr + + client, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + + resp, err := client.Get(origin.URL) + if err != nil { + t.Fatalf("client.Get through CONNECT proxy: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + if sawClientCN != "httpkit-test" { + t.Fatalf("origin saw client CN %q, want %q — mTLS did not reach the origin over the tunnel", sawClientCN, "httpkit-test") + } +} + +// TestNew_PQCHybridCurve_NegotiatesWhenSupported proves a client configured +// with the hybrid group first still negotiates it when the peer supports +// it. +func TestNew_PQCHybridCurve_NegotiatesWhenSupported(t *testing.T) { + cert := newSelfSignedCert(t, "pqc-server", nil, []net.IP{net.ParseIP("127.0.0.1")}) + + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + srv.TLS = &tls.Config{ + Certificates: []tls.Certificate{cert.tlsCert}, + CurvePreferences: []tls.CurveID{tls.X25519MLKEM768}, + } + srv.StartTLS() + defer srv.Close() + + cfg := DefaultConfig() + cfg.TLS.RootCAs = cert.pool + cfg.TLS.CurvePreferences = "X25519MLKEM768,X25519,P-256" + client, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("client.Get: %v", err) + } + defer resp.Body.Close() + + if resp.TLS == nil { + t.Fatal("resp.TLS is nil") + } + if got := tlsconfig.NegotiatedCurveName(*resp.TLS); got != "X25519MLKEM768" { + t.Fatalf("negotiated curve = %q, want %q", got, "X25519MLKEM768") + } +} + +// TestNew_PQCHybridCurve_FallsBackToClassical proves a client configured +// with the hybrid group first still completes the handshake — falling back +// to a classical curve, never hard-failing — against a peer that only +// supports classical groups (a stand-in for a legacy backend that hasn't +// adopted PQC yet). +func TestNew_PQCHybridCurve_FallsBackToClassical(t *testing.T) { + cert := newSelfSignedCert(t, "pqc-server", nil, []net.IP{net.ParseIP("127.0.0.1")}) + + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + srv.TLS = &tls.Config{ + Certificates: []tls.Certificate{cert.tlsCert}, + CurvePreferences: []tls.CurveID{tls.X25519}, // no hybrid support + } + srv.StartTLS() + defer srv.Close() + + cfg := DefaultConfig() + cfg.TLS.RootCAs = cert.pool + cfg.TLS.CurvePreferences = "X25519MLKEM768,X25519,P-256" // hybrid first, classical retained + client, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("client.Get against a classical-only peer must still succeed via fallback, got: %v", err) + } + defer resp.Body.Close() + + if resp.TLS == nil { + t.Fatal("resp.TLS is nil") + } + if got := tlsconfig.NegotiatedCurveName(*resp.TLS); got != "X25519" { + t.Fatalf("negotiated curve = %q, want fallback %q", got, "X25519") + } +} diff --git a/httpkit/httpclient/transport_test.go b/httpkit/httpclient/transport_test.go new file mode 100644 index 0000000000..aec6f40cb2 --- /dev/null +++ b/httpkit/httpclient/transport_test.go @@ -0,0 +1,87 @@ +package httpclient + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestMaxBytesReadCloser_AllowsExactLimit(t *testing.T) { + body := io.NopCloser(strings.NewReader("0123456789")) // exactly 10 bytes + m := &maxBytesReadCloser{body: body, max: 10} + + data, err := io.ReadAll(m) + if err != nil { + t.Fatalf("expected a body of exactly the limit to read cleanly, got: %v", err) + } + if string(data) != "0123456789" { + t.Fatalf("data = %q", data) + } +} + +func TestMaxBytesReadCloser_ErrorsPastLimit(t *testing.T) { + body := io.NopCloser(strings.NewReader("0123456789EXTRA")) + m := &maxBytesReadCloser{body: body, max: 10} + + _, err := io.ReadAll(m) + if err == nil { + t.Fatal("expected an error for a body exceeding the configured limit") + } +} + +// TestNew_ResponseBodyIsBoundedByDefault proves DefaultConfig's +// MaxResponseBytes is actually enforced end-to-end through New, not just +// present as an unused config field. +func TestNew_ResponseBodyIsBoundedByDefault(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(make([]byte, 1024)) + })) + defer srv.Close() + + cfg := DefaultConfig() + cfg.Timeouts.MaxResponseBytes = 100 // well under the 1024-byte response + client, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("client.Get: %v", err) + } + defer resp.Body.Close() + + if _, err := io.ReadAll(resp.Body); err == nil { + t.Fatal("expected reading a response body over the configured MaxResponseBytes to fail") + } +} + +func TestNew_ResponseBodyBoundCanBeDisabled(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(make([]byte, 1024)) + })) + defer srv.Close() + + cfg := DefaultConfig() + cfg.Timeouts.MaxResponseBytes = -1 // opt-out + client, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("client.Get: %v", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("expected the response to read fully when the bound is disabled, got: %v", err) + } + if len(data) != 1024 { + t.Fatalf("len(data) = %d, want 1024", len(data)) + } +} diff --git a/httpkit/httpclient/transport_tier2_test.go b/httpkit/httpclient/transport_tier2_test.go new file mode 100644 index 0000000000..04c4950389 --- /dev/null +++ b/httpkit/httpclient/transport_tier2_test.go @@ -0,0 +1,127 @@ +package httpclient + +import ( + "crypto/tls" + "crypto/x509" + "net" + "net/http" + "net/http/httptest" + "sync" + "testing" +) + +// startConnectTLSProxy starts a CONNECT-speaking forward proxy whose own +// connection requires a client certificate (mTLS to the proxy), verified +// against clientCAs. It records the CommonName of the last client +// certificate it saw so the test can assert the PROXY received the +// proxy-facing certificate rather than the origin-facing one. It reuses +// handleConnectConn (defined in transport_connect_test.go) to relay the +// tunnel once the proxy-facing TLS handshake completes. +func startConnectTLSProxy(t *testing.T, serverCert tls.Certificate, clientCAs *x509.CertPool) (addr string, sawClientCN func() string) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + var mu sync.Mutex + var cn string + + go func() { + for { + raw, err := ln.Accept() + if err != nil { + return + } + go func() { + defer raw.Close() + tlsConn := tls.Server(raw, &tls.Config{ + Certificates: []tls.Certificate{serverCert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: clientCAs, + }) + if err := tlsConn.Handshake(); err != nil { + return + } + state := tlsConn.ConnectionState() + if len(state.PeerCertificates) > 0 { + mu.Lock() + cn = state.PeerCertificates[0].Subject.CommonName + mu.Unlock() + } + handleConnectConn(tlsConn) + }() + } + }() + + return ln.Addr().String(), func() string { + mu.Lock() + defer mu.Unlock() + return cn + } +} + +// TestNew_DistinctProxyAndOriginCerts proves Tier 2 (Proxy.ProxyTLS set) +// genuinely decouples the proxy-facing TLS handshake from the origin-facing +// one: the proxy must see the proxy client certificate, and the origin must +// see a DIFFERENT, origin client certificate, over the same tunneled +// connection. +func TestNew_DistinctProxyAndOriginCerts(t *testing.T) { + proxyServerCert := newSelfSignedCert(t, "proxy-server", nil, []net.IP{net.ParseIP("127.0.0.1")}) + proxyClientCert := newSelfSignedCert(t, "proxy-client-cn", []string{"proxy-client"}, nil) + originServerCert := newSelfSignedCert(t, "origin-server", nil, []net.IP{net.ParseIP("127.0.0.1")}) + originClientCert := newSelfSignedCert(t, "origin-client-cn", []string{"origin-client"}, nil) + + var sawOriginCN string + origin := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(r.TLS.PeerCertificates) > 0 { + sawOriginCN = r.TLS.PeerCertificates[0].Subject.CommonName + } + w.WriteHeader(http.StatusOK) + })) + origin.TLS = &tls.Config{ + Certificates: []tls.Certificate{originServerCert.tlsCert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: originClientCert.pool, + } + origin.StartTLS() + defer origin.Close() + + proxyAddr, sawProxyCN := startConnectTLSProxy(t, proxyServerCert.tlsCert, proxyClientCert.pool) + + cfg := DefaultConfig() + cfg.TLS.RootCAs = originServerCert.pool + cfg.TLS.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { + return &originClientCert.tlsCert, nil + } + cfg.Proxy.Mode = "url" + cfg.Proxy.URL = "https://" + proxyAddr + cfg.Proxy.ProxyTLS = &ProxyTLSConfig{ + RootCAs: proxyServerCert.pool, + GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { + return &proxyClientCert.tlsCert, nil + }, + } + + client, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + + resp, err := client.Get(origin.URL) + if err != nil { + t.Fatalf("client.Get through mTLS proxy tunnel: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + + if got := sawProxyCN(); got != "proxy-client-cn" { + t.Fatalf("proxy saw client CN %q, want the proxy cert's CN %q", got, "proxy-client-cn") + } + if sawOriginCN != "origin-client-cn" { + t.Fatalf("origin saw client CN %q, want the origin cert's CN %q", sawOriginCN, "origin-client-cn") + } +} diff --git a/httpkit/netguard/netguard.go b/httpkit/netguard/netguard.go new file mode 100644 index 0000000000..4510d5c9b1 --- /dev/null +++ b/httpkit/netguard/netguard.go @@ -0,0 +1,264 @@ +// Package netguard provides a shared, dial-time SSRF guard for outbound +// HTTP(S) calls whose destination is influenced, even partially, by +// untrusted input (request bodies, tenant configuration, redirects). +// +// The guard resolves the target host and validates every candidate address +// against a caller-supplied Policy inside the dial itself — not as a +// separate pre-check — so a DNS answer that changes between a check and a +// later connection attempt (DNS rebinding) cannot smuggle a disallowed +// address past the guard. Once a connection has been dialed and validated, +// reusing it from an http.Transport's connection pool carries no additional +// rebinding risk: the socket is already bound to the specific IP that was +// checked, and rebinding only affects a *future* dial for that hostname. +// +// netguard has no built-in opinion on which addresses are legitimate — that +// is a property of the caller's own threat model (see Policy and the +// presets in presets.go) — and it has no awareness of HTTP proxying. A +// dial-time guard wired via DialContext only ever sees, and can only ever +// validate, the address this process itself dials; when a forward proxy is +// configured, that address is the proxy's, never the proxied origin's. See +// the httpclient package for how the two are composed. +package netguard + +import ( + "context" + "fmt" + "net" + "net/http" + "strings" + "time" +) + +// Policy describes which resolved IP addresses a guarded dial may connect +// to. The zero value rejects every address — a Policy must be built via one +// of the presets in presets.go, or assembled explicitly, before use; there +// is deliberately no "default" stance, since two legitimate use cases +// already in this codebase disagree (one permits private/RFC1918 ranges as +// ordinary upstreams, the other requires a fully public address). +type Policy struct { + // BlockPrivate refuses RFC 1918 (IPv4) and unique local (IPv6 ULA, + // fc00::/7) addresses. + BlockPrivate bool + // BlockLoopback refuses 127.0.0.0/8 and ::1. + BlockLoopback bool + // BlockLinkLocal refuses 169.254.0.0/16 and fe80::/10 — this is where + // the cloud instance metadata endpoint (169.254.169.254) lives, so this + // is refused by every preset regardless of the private-address stance. + BlockLinkLocal bool + // BlockUnspecified refuses 0.0.0.0 and ::, which the OS can reinterpret + // as "local host". + BlockUnspecified bool + // BlockMulticastBroadcast refuses multicast and the IPv4 broadcast + // address, neither of which is a meaningful HTTP peer. + BlockMulticastBroadcast bool + // BlockCGNAT refuses 100.64.0.0/10 (RFC 6598 carrier-grade NAT shared + // address space), which net.IP.IsPrivate does not cover but which can + // still route to internal infrastructure. + BlockCGNAT bool + + // DenyCIDRs lists additional address ranges to refuse, beyond the + // Block* categories above (e.g. an operator's own internal VPC CIDR). + DenyCIDRs []*net.IPNet + // AllowCIDRs narrows DenyCIDRs and the Block* categories: an address + // matching AllowCIDRs is permitted even if it would otherwise be + // refused. This is an explicit, off-by-default admin opt-in — it must + // never be used to widen policy implicitly, only to carve out a + // specific, deliberately-approved exception. + AllowCIDRs []*net.IPNet + + // AllowedSchemes lists the URL schemes CheckRedirect permits a redirect + // to target. Empty defaults to {"https"} — "http" must be added + // explicitly. + AllowedSchemes []string +} + +// allowed reports whether ip is permitted by the policy. +func (p Policy) allowed(ip net.IP) bool { + if ip == nil { + return false + } + + for _, cidr := range p.AllowCIDRs { + if cidr.Contains(ip) { + return true + } + } + + if p.BlockLoopback && ip.IsLoopback() { + return false + } + if p.BlockPrivate && (ip.IsPrivate() || isIPv6ULA(ip)) { + return false + } + if p.BlockLinkLocal && (ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()) { + return false + } + if p.BlockUnspecified && ip.IsUnspecified() { + return false + } + if p.BlockMulticastBroadcast && (ip.IsMulticast() || ip.Equal(net.IPv4bcast)) { + return false + } + if p.BlockCGNAT && cgnatRange.Contains(ip) { + return false + } + for _, cidr := range p.DenyCIDRs { + if cidr.Contains(ip) { + return false + } + } + return true +} + +// isIPv6ULA reports whether ip is an IPv6 Unique Local Address (fc00::/7). +// net.IP has no built-in check for this range. +func isIPv6ULA(ip net.IP) bool { + ip4 := ip.To4() + if ip4 != nil { + return false + } + return len(ip) == net.IPv6len && ip[0]&0xfe == 0xfc +} + +// cgnatRange is RFC 6598 shared address space (carrier-grade NAT): +// 100.64.0.0/10. net.IP has no built-in check for this range, yet it can +// route to internal infrastructure. +var cgnatRange = func() *net.IPNet { + _, n, err := net.ParseCIDR("100.64.0.0/10") + if err != nil { + panic("netguard: invalid built-in CGNAT CIDR: " + err.Error()) + } + return n +}() + +// allowedSchemes returns p.AllowedSchemes, defaulting to {"https"} when +// unset. +func (p Policy) allowedSchemes() []string { + if len(p.AllowedSchemes) > 0 { + return p.AllowedSchemes + } + return []string{"https"} +} + +func (p Policy) schemeAllowed(scheme string) bool { + for _, s := range p.allowedSchemes() { + if strings.EqualFold(s, scheme) { + return true + } + } + return false +} + +// DialContext returns a dial function suitable for http.Transport.DialContext +// (or direct use with a net.Dialer-shaped caller) that resolves the target +// host, refuses to dial any candidate address the policy disallows, and then +// dials the exact resolved address it just approved — never the original +// hostname string again. Performing the resolution and the connection in a +// single step is what closes the DNS-rebinding window: a name that resolves +// to an allowed address during validation cannot resolve to a different one +// by the time the connection is actually made, because no time passes +// between the two. +// +// Rejections return a generic error; the specific resolved address and +// reason are deliberately not included, so a caller returning this error to +// an end user does not leak internal network topology. Callers that want the +// concrete reason for internal logging should wrap this dialer and inspect +// the address themselves before calling it. +func DialContext(policy Policy, timeout time.Duration) func(context.Context, string, 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, fmt.Errorf("netguard: invalid address") + } + + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, fmt.Errorf("netguard: failed to resolve host") + } + if len(ips) == 0 { + return nil, fmt.Errorf("netguard: host has no addresses") + } + for _, ip := range ips { + if !policy.allowed(ip.IP) { + return nil, fmt.Errorf("netguard: host resolves to a disallowed address") + } + } + + dialer := &net.Dialer{Timeout: timeout} + var lastErr error + for _, ip := range ips { + conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port)) + if dialErr == nil { + return conn, nil + } + lastErr = dialErr + } + if lastErr != nil { + return nil, fmt.Errorf("netguard: failed to connect to host") + } + return nil, fmt.Errorf("netguard: failed to connect to host") + } +} + +// Validate resolves host and checks every candidate address against +// policy, without dialing. It exists for callers that must validate a +// destination locally before routing the actual connection somewhere this +// package has no visibility into — e.g. httpclient's ProxyEgressManualCONNECT +// mode, which validates an origin hostname before handing it to a forward +// proxy in a CONNECT request. This is defense-in-depth only: it reflects +// what THIS process resolves, not necessarily what a downstream proxy will +// actually connect to. +func Validate(ctx context.Context, policy Policy, host string) error { + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return fmt.Errorf("netguard: failed to resolve host") + } + if len(ips) == 0 { + return fmt.Errorf("netguard: host has no addresses") + } + for _, ip := range ips { + if !policy.allowed(ip.IP) { + return fmt.Errorf("netguard: host resolves to a disallowed address") + } + } + return nil +} + +// defaultMaxRedirects is used by CheckRedirect when maxRedirects <= 0. +const defaultMaxRedirects = 5 + +// CheckRedirect builds an http.Client.CheckRedirect callback that bounds +// redirect loops, restricts the scheme of each hop to policy.AllowedSchemes, +// and refuses any hop that leaves the host of the original request. +// +// The host restriction exists because callers of a guarded client routinely +// pass their own credentials as headers (an upstream's auth header, for +// instance); net/http only strips Authorization/Cookie-style headers across +// a cross-host redirect, and forwards any custom header name verbatim. A +// malicious or compromised upstream could otherwise answer with a redirect +// to a host it controls and be handed the caller's credential. Same-host +// redirects still dial through the guarded DialContext, so the address +// policy is re-applied on every hop, not just the first. +// +// maxRedirects <= 0 uses defaultMaxRedirects (5); a negative value is not +// distinguished from zero — pass a Transport/Client with CheckRedirect +// itself set to reject all redirects if none should ever be followed. +func CheckRedirect(policy Policy, maxRedirects int) func(*http.Request, []*http.Request) error { + if maxRedirects <= 0 { + maxRedirects = defaultMaxRedirects + } + return func(req *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return fmt.Errorf("netguard: too many redirects") + } + if !policy.schemeAllowed(req.URL.Scheme) { + return fmt.Errorf("netguard: redirect to a disallowed scheme") + } + // via[0] is the original request; Host carries the port, so a port + // change counts as a different host too. + if len(via) > 0 && !strings.EqualFold(req.URL.Host, via[0].URL.Host) { + return fmt.Errorf("netguard: redirect to a different host") + } + return nil + } +} diff --git a/httpkit/netguard/netguard_test.go b/httpkit/netguard/netguard_test.go new file mode 100644 index 0000000000..b3aec93095 --- /dev/null +++ b/httpkit/netguard/netguard_test.go @@ -0,0 +1,223 @@ +package netguard + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func mustCIDR(t *testing.T, s string) *net.IPNet { + t.Helper() + _, n, err := net.ParseCIDR(s) + if err != nil { + t.Fatalf("ParseCIDR(%q): %v", s, err) + } + return n +} + +func TestPolicyAllowed(t *testing.T) { + tests := []struct { + name string + policy Policy + ip string + want bool + }{ + {name: "permit-private allows RFC1918", policy: PermitPrivateBlockMetadata(), ip: "10.0.0.5", want: true}, + {name: "permit-private allows loopback", policy: PermitPrivateBlockMetadata(), ip: "127.0.0.1", want: true}, + {name: "permit-private blocks link-local metadata", policy: PermitPrivateBlockMetadata(), ip: "169.254.169.254", want: false}, + {name: "permit-private blocks unspecified v4", policy: PermitPrivateBlockMetadata(), ip: "0.0.0.0", want: false}, + {name: "permit-private blocks unspecified v6", policy: PermitPrivateBlockMetadata(), ip: "::", want: false}, + {name: "permit-private blocks multicast", policy: PermitPrivateBlockMetadata(), ip: "224.0.0.1", want: false}, + {name: "permit-private blocks broadcast", policy: PermitPrivateBlockMetadata(), ip: "255.255.255.255", want: false}, + {name: "permit-private allows public", policy: PermitPrivateBlockMetadata(), ip: "8.8.8.8", want: true}, + + {name: "public-only blocks RFC1918", policy: PublicOnly(), ip: "10.0.0.5", want: false}, + {name: "public-only blocks loopback", policy: PublicOnly(), ip: "127.0.0.1", want: false}, + {name: "public-only blocks link-local metadata", policy: PublicOnly(), ip: "169.254.169.254", want: false}, + {name: "public-only blocks CGNAT", policy: PublicOnly(), ip: "100.64.0.1", want: false}, + {name: "public-only blocks IPv6 ULA", policy: PublicOnly(), ip: "fd00::1", want: false}, + {name: "public-only allows public", policy: PublicOnly(), ip: "8.8.8.8", want: true}, + + { + name: "AllowCIDRs overrides an otherwise-blocked address", + policy: Policy{ + BlockLinkLocal: true, + AllowCIDRs: []*net.IPNet{mustCIDR(t, "169.254.169.0/24")}, + }, + ip: "169.254.169.254", + want: true, + }, + { + name: "DenyCIDRs blocks an otherwise-allowed address", + policy: Policy{ + DenyCIDRs: []*net.IPNet{mustCIDR(t, "8.8.8.0/24")}, + }, + ip: "8.8.8.8", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ip := net.ParseIP(tt.ip) + if ip == nil { + t.Fatalf("net.ParseIP(%q) returned nil", tt.ip) + } + if got := tt.policy.allowed(ip); got != tt.want { + t.Fatalf("allowed(%s) = %v, want %v", tt.ip, got, tt.want) + } + }) + } +} + +func TestDialContext_RejectsDisallowedAddress(t *testing.T) { + // A loopback listener, but a policy that blocks loopback — the dial + // must be refused before ever reaching net.Dial. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen: %v", err) + } + defer ln.Close() + + dial := DialContext(Policy{BlockLoopback: true}, time.Second) + _, err = dial(context.Background(), "tcp", ln.Addr().String()) + if err == nil { + t.Fatal("expected dial to a blocked loopback address to fail") + } +} + +func TestDialContext_AllowsPermittedAddress(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen: %v", err) + } + defer ln.Close() + go func() { + conn, err := ln.Accept() + if err == nil { + conn.Close() + } + }() + + dial := DialContext(PermitPrivateBlockMetadata(), time.Second) + conn, err := dial(context.Background(), "tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("expected dial to a permitted loopback address to succeed, got: %v", err) + } + conn.Close() +} + +// TestDialContext_RevalidatesEveryDial proves the guard re-resolves and +// re-validates on every call rather than caching a result from an earlier +// dial — the property that closes the DNS-rebinding TOCTOU window. It does +// so by pointing the "host" at a loopback address once permitted, then +// swapping the policy to block it and confirming a subsequent dial to the +// same address is rejected: the guard has no memory of the earlier +// decision, so a rebound name is re-checked every time, not trusted from a +// prior pass. +func TestDialContext_RevalidatesEveryDial(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Listen: %v", err) + } + defer ln.Close() + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + conn.Close() + } + }() + + permissive := DialContext(PermitPrivateBlockMetadata(), time.Second) + conn, err := permissive(context.Background(), "tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("expected first dial to succeed, got: %v", err) + } + conn.Close() + + strict := DialContext(PublicOnly(), time.Second) + if _, err := strict(context.Background(), "tcp", ln.Addr().String()); err == nil { + t.Fatal("expected a second dial under a stricter policy to be re-validated and rejected") + } +} + +func TestCheckRedirect(t *testing.T) { + origin, err := http.NewRequest(http.MethodGet, "https://example.com/start", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + + t.Run("rejects too many redirects", func(t *testing.T) { + check := CheckRedirect(Policy{}, 1) + next, _ := http.NewRequest(http.MethodGet, "https://example.com/next", nil) + if err := check(next, []*http.Request{origin}); err == nil { + t.Fatal("expected redirect count over the max to be rejected") + } + }) + + t.Run("rejects disallowed scheme", func(t *testing.T) { + check := CheckRedirect(Policy{}, 5) + next, _ := http.NewRequest(http.MethodGet, "ftp://example.com/next", nil) + if err := check(next, []*http.Request{origin}); err == nil { + t.Fatal("expected a non-allowlisted scheme to be rejected") + } + }) + + t.Run("rejects cross-host redirect", func(t *testing.T) { + check := CheckRedirect(Policy{}, 5) + next, _ := http.NewRequest(http.MethodGet, "https://evil.example/next", nil) + if err := check(next, []*http.Request{origin}); err == nil { + t.Fatal("expected a cross-host redirect to be rejected") + } + }) + + t.Run("allows same-host redirect within limits", func(t *testing.T) { + check := CheckRedirect(Policy{}, 5) + next, _ := http.NewRequest(http.MethodGet, "https://example.com/next", nil) + if err := check(next, []*http.Request{origin}); err != nil { + t.Fatalf("expected same-host redirect to be allowed, got: %v", err) + } + }) +} + +// TestDialContext_EndToEndWithHTTPClient proves the guard composes correctly +// with a real http.Client/Transport, including redirect handling. +func TestDialContext_EndToEndWithHTTPClient(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + client := &http.Client{ + Transport: &http.Transport{ + DialContext: DialContext(PermitPrivateBlockMetadata(), 2*time.Second), + }, + CheckRedirect: CheckRedirect(Policy{AllowedSchemes: []string{"http", "https"}}, 5), + } + + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("client.Get: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } +} + +func TestDialContext_InvalidAddress(t *testing.T) { + dial := DialContext(PermitPrivateBlockMetadata(), time.Second) + _, err := dial(context.Background(), "tcp", "not-a-valid-address") + if err == nil { + t.Fatal("expected an error for an address with no port") + } + if _, ok := errors.AsType[net.Error](err); ok { + t.Fatalf("expected a sterile netguard error, got a raw net.Error: %v", err) + } +} diff --git a/httpkit/netguard/presets.go b/httpkit/netguard/presets.go new file mode 100644 index 0000000000..f08ac654ad --- /dev/null +++ b/httpkit/netguard/presets.go @@ -0,0 +1,37 @@ +package netguard + +// PermitPrivateBlockMetadata returns the policy appropriate for calls to an +// operator- or tenant-configured backend that is normally *meant* to be +// private — a Kubernetes ClusterIP, a service-DNS name resolving into RFC +// 1918 space, or a localhost port during development. Private, loopback, and +// carrier-grade-NAT addresses are all permitted so ordinary deployment +// shapes keep working. +// +// What stays refused is the set of addresses that is never a legitimate +// upstream but is a standard SSRF target or a hazard: link-local addresses +// (which is where the cloud instance metadata endpoint 169.254.169.254 +// lives), the unspecified address (which the OS can reinterpret as "local +// host"), and multicast/broadcast addresses. +func PermitPrivateBlockMetadata() Policy { + return Policy{ + BlockLinkLocal: true, + BlockUnspecified: true, + BlockMulticastBroadcast: true, + } +} + +// PublicOnly returns the stricter policy appropriate for fetching a URL that +// is expected to point at the public internet (a vendor endpoint, a +// third-party spec URL) — every private, loopback, link-local, +// carrier-grade-NAT, unspecified, and multicast/broadcast address is +// refused. +func PublicOnly() Policy { + return Policy{ + BlockPrivate: true, + BlockLoopback: true, + BlockLinkLocal: true, + BlockUnspecified: true, + BlockMulticastBroadcast: true, + BlockCGNAT: true, + } +} diff --git a/httpkit/tlsconfig/tlsconfig.go b/httpkit/tlsconfig/tlsconfig.go new file mode 100644 index 0000000000..680e0c7ce5 --- /dev/null +++ b/httpkit/tlsconfig/tlsconfig.go @@ -0,0 +1,164 @@ +// Package tlsconfig parses operator-facing cipher suite, ECDH/curve +// preference, and TLS version names into the crypto/tls identifiers needed +// to build a *tls.Config. The parsing here is direction-neutral: the same +// names and logic apply whether the resulting config is used for an inbound +// (server) or outbound (client) TLS connection. +package tlsconfig + +import ( + "crypto/tls" + "fmt" + "strings" +) + +// CurvesByName maps the curve/group names accepted in a CurvePreferences +// configuration string to Go's crypto/tls group identifiers. +// +// X25519MLKEM768 is the FIPS 203 ML-KEM-768 + X25519 hybrid group, +// implemented natively by Go 1.23+. It is never selected unless a caller +// names it explicitly — this package draws no distinction between "PQC" and +// "classical" curves beyond the name-to-ID mapping itself; whether hybrid +// curves are the default is a decision for the caller's own configuration, +// not this package. +var CurvesByName = map[string]tls.CurveID{ + "X25519": tls.X25519, + "P-256": tls.CurveP256, + "P-384": tls.CurveP384, + "P-521": tls.CurveP521, + "X25519MLKEM768": tls.X25519MLKEM768, +} + +// ParseCurvePreferences parses a comma-separated curve/group preference list +// (e.g. "X25519MLKEM768,X25519,P-256") into the []tls.CurveID slice consumed +// by tls.Config.CurvePreferences, preserving order — order is the caller's +// preference ranking and is significant for interop (a hybrid group must be +// listed before, not instead of, a classical fallback for a peer that +// doesn't support it yet). An empty string returns (nil, nil): Go's own +// default preference list applies. +func ParseCurvePreferences(raw string) ([]tls.CurveID, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + parts := strings.Split(raw, ",") + curves := make([]tls.CurveID, 0, len(parts)) + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" { + continue + } + curve, ok := CurvesByName[name] + if !ok { + return nil, fmt.Errorf("unsupported curve/group %q (supported: X25519, P-256, P-384, P-521, X25519MLKEM768)", name) + } + curves = append(curves, curve) + } + if len(curves) == 0 { + return nil, fmt.Errorf("must specify at least one curve, or omit entirely to use the default preference list") + } + return curves, nil +} + +// versionByName maps the version strings accepted in a MinVersion/MaxVersion +// configuration to Go's crypto/tls version identifiers. +var versionByName = map[string]uint16{ + "TLS1_0": tls.VersionTLS10, + "TLS1_1": tls.VersionTLS11, + "TLS1_2": tls.VersionTLS12, + "TLS1_3": tls.VersionTLS13, +} + +// versionOrder ranks the version names above so a min > max combination can +// be rejected by ValidateVersionRange. +var versionOrder = map[string]int{ + "TLS1_0": 0, + "TLS1_1": 1, + "TLS1_2": 2, + "TLS1_3": 3, +} + +// ParseVersion converts a version name to its crypto/tls identifier. Callers +// that need to validate a min/max pair together should call +// ValidateVersionRange first; ok is false for an unrecognized name rather +// than panicking. +func ParseVersion(name string) (version uint16, ok bool) { + version, ok = versionByName[name] + return version, ok +} + +// ValidateVersionRange checks that minVersion and maxVersion are both +// recognized version names and that minVersion does not come after +// maxVersion. Both empty is treated as "unset" (Go's own defaults apply); +// exactly one empty is rejected, since that combination cannot express a +// coherent bound. +func ValidateVersionRange(minVersion, maxVersion string) error { + if minVersion == "" && maxVersion == "" { + return nil + } + if minVersion == "" || maxVersion == "" { + return fmt.Errorf("min_version and max_version must both be set, or both left empty to use Go's defaults") + } + if _, ok := versionByName[minVersion]; !ok { + return fmt.Errorf("min_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", minVersion) + } + if _, ok := versionByName[maxVersion]; !ok { + return fmt.Errorf("max_version must be one of TLS1_0, TLS1_1, TLS1_2, TLS1_3, got: %q", maxVersion) + } + if versionOrder[minVersion] > versionOrder[maxVersion] { + return fmt.Errorf("min_version (%s) cannot be greater than max_version (%s)", minVersion, maxVersion) + } + return nil +} + +// cipherSuiteByName is built from Go's own list of secure cipher suites +// (tls.CipherSuites — deliberately excludes tls.InsecureCipherSuites) so a +// caller can only ever restrict to suites Go itself considers safe, never +// re-enable a weak one. +var cipherSuiteByName = func() map[string]uint16 { + m := make(map[string]uint16) + for _, cs := range tls.CipherSuites() { + m[cs.Name] = cs.ID + } + return m +}() + +// ParseCipherSuites parses a comma-separated list of Go crypto/tls cipher +// suite names (e.g. "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256") into the +// []uint16 consumed by tls.Config.CipherSuites. An empty string is valid and +// returns (nil, nil) — Go's own default suite set/order applies. This only +// affects TLS 1.2 and below; TLS 1.3 suite selection is not configurable in +// Go and always uses its own safe set. +func ParseCipherSuites(raw string) ([]uint16, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + parts := strings.Split(raw, ",") + suites := make([]uint16, 0, len(parts)) + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" { + continue + } + id, ok := cipherSuiteByName[name] + if !ok { + return nil, fmt.Errorf("unsupported or insecure cipher suite %q (see crypto/tls.CipherSuites for the supported list)", name) + } + suites = append(suites, id) + } + if len(suites) == 0 { + return nil, fmt.Errorf("must specify at least one cipher suite, or omit entirely to use Go's default set") + } + return suites, nil +} + +// NegotiatedCurveName returns the human-readable name of the curve/group +// negotiated on a completed TLS connection (e.g. "X25519MLKEM768", "X25519"), +// or the numeric fallback Go's own CurveID.String() produces for a group +// this package doesn't otherwise name. Intended for logging/status +// reporting so operators can confirm whether a given connection actually +// negotiated a post-quantum hybrid group or fell back to a classical one, as +// required whenever hybrid PQC support is enabled. +func NegotiatedCurveName(cs tls.ConnectionState) string { + return cs.CurveID.String() +} diff --git a/httpkit/tlsconfig/tlsconfig_test.go b/httpkit/tlsconfig/tlsconfig_test.go new file mode 100644 index 0000000000..272c42ccf1 --- /dev/null +++ b/httpkit/tlsconfig/tlsconfig_test.go @@ -0,0 +1,125 @@ +package tlsconfig + +import ( + "crypto/tls" + "testing" +) + +func TestParseCurvePreferences(t *testing.T) { + tests := []struct { + name string + raw string + want []tls.CurveID + wantErr bool + }{ + {name: "empty uses default", raw: "", want: nil}, + {name: "single curve", raw: "X25519", want: []tls.CurveID{tls.X25519}}, + { + name: "hybrid first then classical fallback, order preserved", + raw: "X25519MLKEM768,X25519,P-256", + want: []tls.CurveID{tls.X25519MLKEM768, tls.X25519, tls.CurveP256}, + }, + {name: "trims whitespace", raw: " X25519 , P-256 ", want: []tls.CurveID{tls.X25519, tls.CurveP256}}, + {name: "unknown curve rejected", raw: "Curve25519", wantErr: true}, + {name: "only commas is empty after trim", raw: ",,", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseCurvePreferences(tt.raw) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseCurvePreferences(%q) error = %v, wantErr %v", tt.raw, err, tt.wantErr) + } + if tt.wantErr { + return + } + if len(got) != len(tt.want) { + t.Fatalf("ParseCurvePreferences(%q) = %v, want %v", tt.raw, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("ParseCurvePreferences(%q)[%d] = %v, want %v", tt.raw, i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestParseCipherSuites(t *testing.T) { + if suites, err := ParseCipherSuites(""); err != nil || suites != nil { + t.Fatalf("ParseCipherSuites(\"\") = %v, %v, want nil, nil", suites, err) + } + + // Pick a real secure suite name from Go's own list rather than hardcoding + // one, so this test doesn't rot if Go's secure-suite set ever changes. + secureSuites := tls.CipherSuites() + if len(secureSuites) == 0 { + t.Fatal("tls.CipherSuites() returned no suites") + } + name := secureSuites[0].Name + got, err := ParseCipherSuites(name) + if err != nil { + t.Fatalf("ParseCipherSuites(%q) unexpected error: %v", name, err) + } + if len(got) != 1 || got[0] != secureSuites[0].ID { + t.Fatalf("ParseCipherSuites(%q) = %v, want [%v]", name, got, secureSuites[0].ID) + } + + if _, err := ParseCipherSuites("TLS_NOT_A_REAL_SUITE"); err == nil { + t.Fatal("ParseCipherSuites accepted an unknown suite name") + } + + // Every suite returned by tls.InsecureCipherSuites must be rejected — + // this package must never let a caller re-enable a weak suite. + for _, cs := range tls.InsecureCipherSuites() { + if _, err := ParseCipherSuites(cs.Name); err == nil { + t.Fatalf("ParseCipherSuites accepted insecure suite %q", cs.Name) + } + } +} + +func TestValidateVersionRange(t *testing.T) { + tests := []struct { + name string + min, max string + wantErr bool + }{ + {name: "both empty is valid (use Go defaults)", min: "", max: ""}, + {name: "valid range", min: "TLS1_2", max: "TLS1_3"}, + {name: "equal min and max is valid", min: "TLS1_3", max: "TLS1_3"}, + {name: "min only is invalid", min: "TLS1_2", max: "", wantErr: true}, + {name: "max only is invalid", min: "", max: "TLS1_3", wantErr: true}, + {name: "unknown min rejected", min: "TLS9_9", max: "TLS1_3", wantErr: true}, + {name: "unknown max rejected", min: "TLS1_2", max: "TLS9_9", wantErr: true}, + {name: "min greater than max rejected", min: "TLS1_3", max: "TLS1_2", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateVersionRange(tt.min, tt.max) + if (err != nil) != tt.wantErr { + t.Fatalf("ValidateVersionRange(%q, %q) error = %v, wantErr %v", tt.min, tt.max, err, tt.wantErr) + } + }) + } +} + +func TestParseVersion(t *testing.T) { + v, ok := ParseVersion("TLS1_3") + if !ok || v != tls.VersionTLS13 { + t.Fatalf("ParseVersion(TLS1_3) = %v, %v, want %v, true", v, ok, tls.VersionTLS13) + } + if _, ok := ParseVersion("bogus"); ok { + t.Fatal("ParseVersion accepted an unknown version name") + } +} + +func TestNegotiatedCurveName(t *testing.T) { + cs := tls.ConnectionState{CurveID: tls.X25519MLKEM768} + if got := NegotiatedCurveName(cs); got != "X25519MLKEM768" { + t.Fatalf("NegotiatedCurveName = %q, want %q", got, "X25519MLKEM768") + } + + cs = tls.ConnectionState{CurveID: tls.X25519} + if got := NegotiatedCurveName(cs); got != "X25519" { + t.Fatalf("NegotiatedCurveName = %q, want %q", got, "X25519") + } +}