Skip to content

Add pkg/ssrf shared egress guard; use it for WithNoLocalIP and analyzer clients - #5288

Merged
bill-rich merged 2 commits into
mainfrom
ssrf-shared-guard
Oct 5, 2026
Merged

bill-rich merged 2 commits into
mainfrom
ssrf-shared-guard

Conversation

@bill-rich

@bill-rich bill-rich commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Description

Centralizes SSRF egress protection into a new pkg/ssrf package so detectors and downstream consumers (including the thog analyzer guard, see trufflesecurity/thog#7229) share one classification policy and one enforcement mechanism.

pkg/ssrf

  • ErrEgressBlocked sentinel, IsNonPublicIP, CheckDialAddress, and GuardDialer.
  • The blocked set is broader than the old isLocalIP: adds CGNAT 100.64.0.0/10 (incl. Alibaba metadata), class E, test-nets and other special-use ranges, all multicast, deprecated IPv6 site-local fec0::/10, and IPv6 embeddings of internal v4 targets that To4() does not normalize (IPv4-compatible ::/96, SIIT, 6to4, Teredo, NAT64 local-use).
  • The NAT64 well-known prefix 64:ff9b::/96 is handled by extracting and classifying the embedded IPv4 rather than blanket-blocking, so DNS64-synthesized addresses of public IPv4-only endpoints keep working in IPv6-only networks while internal embeddings stay blocked.

WithNoLocalIP

Now installs the guard as a dialer ControlContext hook that checks the resolved address immediately before connect. The previous implementation vetted a LookupIP result and then dialed the hostname again, which a DNS-rebinding resolver could race; the hook runs on the address actually being dialed and re-runs on every redirect hop. ErrNoLocalIP remains as a deprecated alias of ssrf.ErrEgressBlocked so errors.Is call sites keep working.

Behavior changes to be aware of (documented in the godoc):

  • Blocking is per resolved address rather than whole-host.
  • Any DialContext already set on the transport is replaced, not chained.
  • The broadened set means guarded detectors (e.g. Artifactory, Vault) no longer verify against CGNAT/Tailscale-hosted endpoints. Flagging explicitly for review since this changes self-hosted behavior.

Analyzer clients

Analyzers take their endpoints from scanned content, 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 CLI and self-hosted behavior is unchanged. Covers NewAnalyzeClient, NewAnalyzeClientUnrestricted, the rate-limiter fallback, and HttpStatusTest.RunTest.

Checklist:

  • Tests passing for the changed packages (go test ./pkg/ssrf ./pkg/analyzer/analyzers ./pkg/detectors); full suite left to CI
  • Lint passing (make lint)? (golangci-lint not installed locally; left to CI)

Note

High Risk
Changes security-sensitive outbound networking for secret verification and optional analyzer egress; broadened blocking and per-IP semantics can break legitimate checks against CGNAT/private endpoints when guards are enabled.

Overview
Introduces pkg/ssrf as the shared policy for blocking outbound dials to non-public addresses when destinations come from untrusted scan content. Enforcement runs in the dialer ControlContext on the resolved IP immediately before connect (DNS-rebinding and redirect-safe), with a broader blocked set than the old isLocalIP logic (CGNAT, metadata-adjacent ranges, multicast, IPv6 smuggling forms, etc.) and special handling so NAT64 64:ff9b::/96 classifies the embedded IPv4 instead of blanket-blocking DNS64-synthesized public targets.

WithNoLocalIP in detectors now wraps ssrf.GuardDialer instead of pre-resolving with LookupIP; ErrNoLocalIP remains as a deprecated alias of ssrf.ErrEgressBlocked. Documented behavior shifts: per-address blocking (mixed A records can still connect on a public IP) and replacement rather than chaining of custom DialContext.

Analyzer HTTP clients (NewAnalyzeClient, unrestricted variant, rate-limiter fallback, HttpStatusTest.RunTest) build on baseTransport(), which applies the guarded transport only when SetEgressRestriction(true) is called—default off so OSS/self-hosted internal verification is unchanged.

Reviewed by Cursor Bugbot for commit 1923347. Bugbot is set up for automated code reviews on this repo. Configure here.

Centralizes SSRF egress protection so detectors and downstream consumers
(the thog analyzer guard) share one classification and one enforcement
mechanism.

pkg/ssrf provides ErrEgressBlocked, IsNonPublicIP, CheckDialAddress, and
GuardDialer. The blocked set is broader than the old isLocalIP: it adds
CGNAT (incl. Alibaba metadata), class E, test-nets and other special-use
ranges, all multicast, and IPv6 embeddings of internal v4 targets (NAT64,
6to4, IPv4-compatible) that To4() does not normalize.

WithNoLocalIP now installs a dialer whose Control hook checks the
resolved address immediately before connect. The previous implementation
vetted a LookupIP result and then dialed the hostname again, which a
DNS-rebinding resolver could race; Control runs on the address actually
being dialed, and re-runs on every redirect hop. ErrNoLocalIP remains as
a deprecated alias of ssrf.ErrEgressBlocked so errors.Is call sites keep
working.
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.
@bill-rich
bill-rich requested a review from a team September 8, 2026 17:15
@bill-rich
bill-rich requested a review from a team as a code owner September 8, 2026 17:15
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Corpora Test Results

No detector regex or keyword changes in this PR. Bench skipped.

@bill-rich
bill-rich merged commit 0186870 into main Oct 5, 2026
18 of 19 checks passed
@bill-rich
bill-rich deleted the ssrf-shared-guard branch October 5, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants