From b6f70919523cb57788198ce27d0522cb59f239b7 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 26 Jun 2026 13:05:23 +0100 Subject: [PATCH] fix: block SSRF bypass through HTTP proxy in restricted outbound client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NewRestrictedOutboundHTTPClient validates target IPs via a custom DialContext, but when an HTTP proxy is configured (HTTP_PROXY env var) DialContext only validates the proxy's address — the actual target host is never checked. This allows requests to cloud metadata service addresses (169.254.169.254) and other blocked IPs through the proxy. Add a restrictedRoundTripper wrapper that validates the request URL hostname against resolvePermittedOutboundIP before forwarding. This provides defense-in-depth that works regardless of proxy configuration, while the existing DialContext guard continues to prevent DNS rebinding for direct connections. --- pkg/securityutil/outbound_http.go | 24 ++++++++++- pkg/securityutil/outbound_http_test.go | 56 ++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 pkg/securityutil/outbound_http_test.go diff --git a/pkg/securityutil/outbound_http.go b/pkg/securityutil/outbound_http.go index 90057a398..42c30f6e9 100644 --- a/pkg/securityutil/outbound_http.go +++ b/pkg/securityutil/outbound_http.go @@ -210,6 +210,28 @@ func cloneRestrictedTransport(tlsConfig *tls.Config) *http.Transport { return clone } +// restrictedRoundTripper wraps an underlying transport and validates the +// request URL hostname against the restricted outbound policy before +// forwarding. This provides defense-in-depth that works even when an HTTP +// proxy is configured: the proxy handles the actual connection, but the +// target host is still validated here so that SSRF targets (e.g. cloud +// metadata service addresses) cannot be reached through the proxy. +type restrictedRoundTripper struct { + base http.RoundTripper + opts RestrictedOutboundHTTPOptions +} + +func (r *restrictedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + host := req.URL.Hostname() + if host == "" { + return nil, fmt.Errorf("URL hostname is required") + } + if _, err := resolvePermittedOutboundIP(req.Context(), host, r.opts); err != nil { + return nil, err + } + return r.base.RoundTrip(req) +} + // NewRestrictedOutboundHTTPClient returns an HTTP client that validates redirects and pins direct outbound dials // to the first permitted resolved IP for the requested host. func NewRestrictedOutboundHTTPClient(timeout time.Duration, opts RestrictedOutboundHTTPOptions) *http.Client { @@ -230,7 +252,7 @@ func NewRestrictedOutboundHTTPClient(timeout time.Duration, opts RestrictedOutbo } client := &http.Client{ - Transport: transport, + Transport: &restrictedRoundTripper{base: transport, opts: opts}, CheckRedirect: sameOriginRedirectPolicy(opts), } if timeout > 0 { diff --git a/pkg/securityutil/outbound_http_test.go b/pkg/securityutil/outbound_http_test.go new file mode 100644 index 000000000..7d794e56a --- /dev/null +++ b/pkg/securityutil/outbound_http_test.go @@ -0,0 +1,56 @@ +package securityutil + +import ( + "context" + "net/http" + "strings" + "testing" +) + +func TestRestrictedOutboundHTTPClient_BlocksMetadataServiceEvenViaProxy(t *testing.T) { + client := NewRestrictedOutboundHTTPClient(0, RestrictedOutboundHTTPOptions{ + AllowedSchemes: []string{"http", "https"}, + AllowPrivateIPs: true, + AllowLoopback: true, + }) + + req, err := http.NewRequestWithContext(context.Background(), "GET", "http://169.254.169.254/api/version", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + resp, err := client.Do(req) + if resp != nil { + resp.Body.Close() + } + if err == nil { + t.Fatal("expected error for metadata service address, got nil") + } + if !strings.Contains(err.Error(), "metadata service address is not allowed") { + t.Fatalf("expected 'metadata service address is not allowed', got %v", err) + } +} + +func TestRestrictedOutboundHTTPClient_BlocksLinkLocalEvenViaProxy(t *testing.T) { + client := NewRestrictedOutboundHTTPClient(0, RestrictedOutboundHTTPOptions{ + AllowedSchemes: []string{"http", "https"}, + AllowPrivateIPs: true, + AllowLoopback: true, + }) + + req, err := http.NewRequestWithContext(context.Background(), "GET", "http://169.254.10.20/test", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + resp, err := client.Do(req) + if resp != nil { + resp.Body.Close() + } + if err == nil { + t.Fatal("expected error for link-local address, got nil") + } + if !strings.Contains(err.Error(), "link-local addresses are not allowed") { + t.Fatalf("expected 'link-local addresses are not allowed', got %v", err) + } +}