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.
This commit is contained in:
Bill Rich
2026-09-08 10:14:36 -07:00
parent d7552f2000
commit 1923347d28
4 changed files with 105 additions and 10 deletions
+1 -1
View File
@@ -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
+64 -3
View File
@@ -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 {
+27 -6
View File
@@ -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
+13
View File
@@ -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 {