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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user