fix: block SSRF bypass through HTTP proxy in restricted outbound client

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.
This commit is contained in:
rcourtman
2026-06-26 13:05:23 +01:00
parent 9bcff2983d
commit b6f7091952
2 changed files with 79 additions and 1 deletions
+23 -1
View File
@@ -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 {
+56
View File
@@ -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)
}
}