From 1923347d28634fe75c8cd83fd05aae02f72b40a2 Mon Sep 17 00:00:00 2001 From: Bill Rich Date: Tue, 8 Sep 2026 10:14:36 -0700 Subject: [PATCH] Close IPv6 classification gaps and guard the analyzer HTTP clients Classification fixes in pkg/ssrf, each pinned by a test: - Block fec0::/10 (deprecated site-local, still internal scope on legacy gear), SIIT IPv4-translated ::ffff:0:0:0/96, and Teredo 2001::/32, which embed v4 targets that To4() does not normalize. - Stop blanket-blocking the NAT64 well-known prefix 64:ff9b::/96. DNS64 resolvers synthesize it for every public IPv4-only endpoint, so blocking the whole prefix broke all guarded dials in IPv6-only networks. IsNonPublicIP now extracts the embedded v4 from the low 32 bits and classifies that instead, so 64:ff9b::a00:1 (10.0.0.1) stays blocked while 64:ff9b::808:808 (8.8.8.8) is reachable. The RFC8215 local-use prefix stays blanket-blocked since its embedding layout is operator-defined. Analyzer client coverage: analyzers take their endpoints from scanned content (a secret's domain, a connection string), but the clients built in pkg/analyzer/analyzers used bare http.DefaultTransport. They now build on a guarded transport behind an opt-in SetEgressRestriction toggle, default off so the CLI and self-hosted behavior is unchanged. This covers NewAnalyzeClient, NewAnalyzeClientUnrestricted, the rate-limiter fallback transport, and HttpStatusTest.RunTest's bare client. --- pkg/analyzer/analyzers/analyzers.go | 2 +- pkg/analyzer/analyzers/client.go | 67 +++++++++++++++++++++++++++-- pkg/ssrf/ssrf.go | 33 +++++++++++--- pkg/ssrf/ssrf_test.go | 13 ++++++ 4 files changed, 105 insertions(+), 10 deletions(-) diff --git a/pkg/analyzer/analyzers/analyzers.go b/pkg/analyzer/analyzers/analyzers.go index 261bf95e2..f2deaecb3 100644 --- a/pkg/analyzer/analyzers/analyzers.go +++ b/pkg/analyzer/analyzers/analyzers.go @@ -211,7 +211,7 @@ func (h *HttpStatusTest) RunTest(headers map[string]string) error { } // Create new HTTP request - client := &http.Client{} + client := &http.Client{Transport: baseTransport()} req, err := http.NewRequest(h.Method, h.URL, data) if err != nil { return err diff --git a/pkg/analyzer/analyzers/client.go b/pkg/analyzer/analyzers/client.go index 0d16d50de..269a030d3 100644 --- a/pkg/analyzer/analyzers/client.go +++ b/pkg/analyzer/analyzers/client.go @@ -2,15 +2,76 @@ package analyzers import ( "fmt" + "net" "net/http" "os" "strings" + "sync/atomic" "time" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" + "github.com/trufflesecurity/trufflehog/v3/pkg/ssrf" "golang.org/x/time/rate" ) +// restrictEgress gates whether analyzer HTTP clients enforce the SSRF egress +// guard from pkg/ssrf. Analyzers take their endpoints from scanned content (a +// secret's domain, a connection string), so a hosted deployment must refuse +// dials into internal address space. Default false preserves behavior for the +// OSS CLI and self-hosted use, which legitimately analyze credentials for +// internal services. +var restrictEgress atomic.Bool + +// SetEgressRestriction enables or disables the analyzer SSRF egress guard. +// When enabled, every client built by this package (NewAnalyzeClient, +// NewAnalyzeClientUnrestricted, HttpStatusTest.RunTest, and the +// RateLimitRoundTripper fallback) refuses to connect to non-public addresses, +// checked after DNS resolution and re-checked on every redirect hop. +func SetEgressRestriction(enabled bool) { + restrictEgress.Store(enabled) +} + +// baseTransport returns the round tripper analyzer clients build on: the +// guarded transport when the egress restriction is enabled, otherwise +// http.DefaultTransport. +func baseTransport() http.RoundTripper { + if restrictEgress.Load() { + return safeEgressTransport + } + return http.DefaultTransport +} + +// safeEgressTransport is http.DefaultTransport with the sole modification of a +// guarded dialer (see ssrf.GuardDialer). Cloning preserves proxy and timeout +// settings; note the pkg/ssrf caveat that a forward proxy moves the final +// connection out of the dialer's sight, so egress policy must then also be +// enforced at the proxy. +var safeEgressTransport = newSafeEgressTransport() + +func newSafeEgressTransport() *http.Transport { + guarded, ok := http.DefaultTransport.(*http.Transport) + if ok { + guarded = guarded.Clone() + } else { + // http.DefaultTransport is always an *http.Transport; this is a + // defensive fallback mirroring the standard library's field values. + guarded = &http.Transport{ + Proxy: http.ProxyFromEnvironment, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } + } + // Mirror http.DefaultTransport's dialer (30s timeout and keep-alive). + guarded.DialContext = ssrf.GuardDialer(&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext + return guarded +} + type AnalyzeClient struct { http.Client LoggingEnabled bool @@ -34,7 +95,7 @@ type ClientOption func(*http.Client) // This returns a client that is restricted and filters out unsafe requests returning a success status code. func NewAnalyzeClient(cfg *config.Config, opts ...func(*http.Client)) *http.Client { client := &http.Client{ - Transport: AnalyzerRoundTripper{parent: http.DefaultTransport}, + Transport: AnalyzerRoundTripper{parent: baseTransport()}, } if cfg != nil && cfg.LoggingEnabled { client = &http.Client{ @@ -53,7 +114,7 @@ func NewAnalyzeClient(cfg *config.Config, opts ...func(*http.Client)) *http.Clie // This returns a client that is unrestricted and does not filter out unsafe requests returning a success status code. func NewAnalyzeClientUnrestricted(cfg *config.Config, opts ...ClientOption) *http.Client { client := &http.Client{ - Transport: http.DefaultTransport, + Transport: baseTransport(), } if cfg != nil && cfg.LoggingEnabled { client = &http.Client{ @@ -151,7 +212,7 @@ type RateLimitRoundTripper struct { func (rt RateLimitRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { if rt.parent == nil { - rt.parent = http.DefaultTransport + rt.parent = baseTransport() } if rt.limiter != nil { if err := rt.limiter.Wait(req.Context()); err != nil { diff --git a/pkg/ssrf/ssrf.go b/pkg/ssrf/ssrf.go index 194d57303..763f074a9 100644 --- a/pkg/ssrf/ssrf.go +++ b/pkg/ssrf/ssrf.go @@ -101,19 +101,35 @@ var extraBlockedCIDRs = mustParseCIDRs( "203.0.113.0/24", // TEST-NET-3 "240.0.0.0/4", // RFC1112 class E reserved (also covers 255.255.255.255 broadcast) // IPv6 embeddings of an IPv4 address that To4() does NOT normalize; without - // these, an internal v4 target can be smuggled in as an IPv6 literal. - "::/96", // RFC4291 IPv4-compatible IPv6 (:: and ::1 are caught earlier) - "64:ff9b::/96", // RFC6052 NAT64 well-known prefix - "64:ff9b:1::/48", // RFC8215 NAT64 local-use prefix - "2002::/16", // RFC3056 6to4 + // these, an internal v4 target can be smuggled in as an IPv6 literal. The + // NAT64 well-known prefix is NOT here: DNS64 legitimately synthesizes it for + // public IPv4-only endpoints, so IsNonPublicIP extracts and classifies the + // embedded v4 instead (see nat64WellKnownPrefix). The mechanisms below are + // deprecated or operator-local, so there is no availability reason to allow + // any of them and they are blocked outright. + "::/96", // RFC4291 IPv4-compatible IPv6, deprecated (:: and ::1 are caught earlier) + "::ffff:0:0:0/96", // RFC2765 SIIT "IPv4-translated"; To4() only normalizes ::ffff:0:0/96 + "64:ff9b:1::/48", // RFC8215 NAT64 local-use prefix; embedded position varies per operator + "2002::/16", // RFC3056 6to4, deprecated + "2001::/32", // RFC4380 Teredo; embeds v4 server/client addresses + // IPv6 ranges with internal or non-routable scope. + "fec0::/10", // RFC3879 site-local, deprecated but still routed as internal scope by legacy gear // IPv6 special-use parity with the v4 test/doc ranges above. "2001:db8::/32", // RFC3849 documentation "100::/64", // RFC6666 discard-only // Note: IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) is handled by the - // To4() normalization in IsNonPublicIP, not by a CIDR here — a + // To4() normalization in IsNonPublicIP, not by a CIDR here, because a // ::ffff:0:0/96 entry would match every IPv4 address. ) +// nat64WellKnownPrefix is the RFC6052 NAT64 well-known prefix 64:ff9b::/96. +// Unlike the deprecated embeddings in extraBlockedCIDRs, DNS64 resolvers +// synthesize these addresses for ordinary public IPv4-only endpoints, so +// blanket-blocking the prefix would break every guarded dial to an IPv4-only +// host in an IPv6-only (DNS64/NAT64) network. Instead the embedded IPv4 in +// the low 32 bits is extracted and classified on its own. +var nat64WellKnownPrefix = mustParseCIDRs("64:ff9b::/96")[0] + // IsNonPublicIP reports whether an IP must not be dialed by a guarded client: // loopback, link-local (incl. cloud metadata), private (RFC1918/RFC4193), // CGNAT, multicast, unspecified, the special-use ranges above, and IPv6 @@ -125,6 +141,11 @@ func IsNonPublicIP(ip net.IP) bool { // Normalize IPv4-in-IPv6 so the v4 classification methods apply. if v4 := ip.To4(); v4 != nil { ip = v4 + } else if ip16 := ip.To16(); ip16 != nil && nat64WellKnownPrefix.Contains(ip16) { + // NAT64 well-known prefix: classify the embedded IPv4 (low 32 bits) so + // DNS64-synthesized addresses of public endpoints stay reachable while + // embeddings of internal targets are still blocked. + ip = net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15]).To4() } if ip.IsLoopback() || // 127.0.0.0/8, ::1 diff --git a/pkg/ssrf/ssrf_test.go b/pkg/ssrf/ssrf_test.go index 10318a760..c059d6fb8 100644 --- a/pkg/ssrf/ssrf_test.go +++ b/pkg/ssrf/ssrf_test.go @@ -69,7 +69,20 @@ func TestIsNonPublicIP(t *testing.T) { {"64:ff9b::a00:1", true}, // NAT64 of 10.0.0.1 {"2002:0a00:0001::1", true}, // 6to4 of 10.0.0.1 {"::0a00:0001", true}, // IPv4-compatible ::10.0.0.1 + {"::ffff:0:a00:1", true}, // SIIT IPv4-translated ::ffff:0:10.0.0.1 + {"2001::a00:1", true}, // Teredo (embeds v4 addresses) {"2001:db8::1", true}, // documentation + + // NAT64 well-known prefix classifies the EMBEDDED v4: DNS64-synthesized + // addresses of public IPv4-only endpoints must stay reachable in + // IPv6-only networks, while internal embeddings stay blocked. + {"64:ff9b::808:808", false}, // NAT64 of 8.8.8.8 + {"64:ff9b::a9fe:a9fe", true}, // NAT64 of 169.254.169.254 + {"64:ff9b:1::a00:1", true}, // RFC8215 local-use prefix stays blanket-blocked + + // Deprecated IPv6 site-local, still internal scope on legacy gear. + {"fec0::1", true}, + {"feff::1", true}, } for _, c := range cases {