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
|
// Create new HTTP request
|
||||||
client := &http.Client{}
|
client := &http.Client{Transport: baseTransport()}
|
||||||
req, err := http.NewRequest(h.Method, h.URL, data)
|
req, err := http.NewRequest(h.Method, h.URL, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -2,15 +2,76 @@ package analyzers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
|
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
|
||||||
|
"github.com/trufflesecurity/trufflehog/v3/pkg/ssrf"
|
||||||
"golang.org/x/time/rate"
|
"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 {
|
type AnalyzeClient struct {
|
||||||
http.Client
|
http.Client
|
||||||
LoggingEnabled bool
|
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.
|
// 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 {
|
func NewAnalyzeClient(cfg *config.Config, opts ...func(*http.Client)) *http.Client {
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Transport: AnalyzerRoundTripper{parent: http.DefaultTransport},
|
Transport: AnalyzerRoundTripper{parent: baseTransport()},
|
||||||
}
|
}
|
||||||
if cfg != nil && cfg.LoggingEnabled {
|
if cfg != nil && cfg.LoggingEnabled {
|
||||||
client = &http.Client{
|
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.
|
// 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 {
|
func NewAnalyzeClientUnrestricted(cfg *config.Config, opts ...ClientOption) *http.Client {
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Transport: http.DefaultTransport,
|
Transport: baseTransport(),
|
||||||
}
|
}
|
||||||
if cfg != nil && cfg.LoggingEnabled {
|
if cfg != nil && cfg.LoggingEnabled {
|
||||||
client = &http.Client{
|
client = &http.Client{
|
||||||
@@ -151,7 +212,7 @@ type RateLimitRoundTripper struct {
|
|||||||
|
|
||||||
func (rt RateLimitRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (rt RateLimitRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
if rt.parent == nil {
|
if rt.parent == nil {
|
||||||
rt.parent = http.DefaultTransport
|
rt.parent = baseTransport()
|
||||||
}
|
}
|
||||||
if rt.limiter != nil {
|
if rt.limiter != nil {
|
||||||
if err := rt.limiter.Wait(req.Context()); err != 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
|
"203.0.113.0/24", // TEST-NET-3
|
||||||
"240.0.0.0/4", // RFC1112 class E reserved (also covers 255.255.255.255 broadcast)
|
"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
|
// 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.
|
// these, an internal v4 target can be smuggled in as an IPv6 literal. The
|
||||||
"::/96", // RFC4291 IPv4-compatible IPv6 (:: and ::1 are caught earlier)
|
// NAT64 well-known prefix is NOT here: DNS64 legitimately synthesizes it for
|
||||||
"64:ff9b::/96", // RFC6052 NAT64 well-known prefix
|
// public IPv4-only endpoints, so IsNonPublicIP extracts and classifies the
|
||||||
"64:ff9b:1::/48", // RFC8215 NAT64 local-use prefix
|
// embedded v4 instead (see nat64WellKnownPrefix). The mechanisms below are
|
||||||
"2002::/16", // RFC3056 6to4
|
// 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.
|
// IPv6 special-use parity with the v4 test/doc ranges above.
|
||||||
"2001:db8::/32", // RFC3849 documentation
|
"2001:db8::/32", // RFC3849 documentation
|
||||||
"100::/64", // RFC6666 discard-only
|
"100::/64", // RFC6666 discard-only
|
||||||
// Note: IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) is handled by the
|
// 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.
|
// ::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:
|
// IsNonPublicIP reports whether an IP must not be dialed by a guarded client:
|
||||||
// loopback, link-local (incl. cloud metadata), private (RFC1918/RFC4193),
|
// loopback, link-local (incl. cloud metadata), private (RFC1918/RFC4193),
|
||||||
// CGNAT, multicast, unspecified, the special-use ranges above, and IPv6
|
// 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.
|
// Normalize IPv4-in-IPv6 so the v4 classification methods apply.
|
||||||
if v4 := ip.To4(); v4 != nil {
|
if v4 := ip.To4(); v4 != nil {
|
||||||
ip = v4
|
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
|
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
|
{"64:ff9b::a00:1", true}, // NAT64 of 10.0.0.1
|
||||||
{"2002:0a00:0001::1", true}, // 6to4 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
|
{"::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
|
{"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 {
|
for _, c := range cases {
|
||||||
|
|||||||
Reference in New Issue
Block a user