mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user