Skip to content

x509util: block private/loopback hosts in URL-fetching helpers#1761

Open
1seal wants to merge 2 commits intogoogle:masterfrom
1seal:fix/ssrf-url-validation-x509util
Open

x509util: block private/loopback hosts in URL-fetching helpers#1761
1seal wants to merge 2 commits intogoogle:masterfrom
1seal:fix/ssrf-url-validation-x509util

Conversation

@1seal
Copy link
Copy Markdown
Contributor

@1seal 1seal commented Feb 12, 2026

Summary

  • GetIssuer, ReadFileOrURL, and ReadPossiblePEMURL accept URLs that may originate from certificate extension fields (AIA, CDP). These fields are controlled by whoever created the certificate.
  • Add rejectPrivateHost check that blocks loopback, link-local, and private IP addresses before issuing HTTP requests, preventing SSRF to internal endpoints.
  • Add missing defer rsp.Body.Close() in ReadPossiblePEMURL and ReadFileOrURL to prevent resource leaks.
  • Add test coverage for the new host validation.

Test plan

  • go test ./x509util/ -v — all existing tests pass, new TestRejectPrivateHost covers loopback (v4/v6), link-local, private ranges (10/172/192), AWS IMDS, and public hosts
  • Verify no regressions in downstream consumers (ctutil/sctcheck, x509util/crlcheck)

@1seal 1seal requested a review from a team as a code owner February 12, 2026 16:21
@1seal 1seal requested review from mhutchinson and removed request for a team February 12, 2026 16:21
@google-cla
Copy link
Copy Markdown

google-cla Bot commented Feb 12, 2026

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@1seal 1seal force-pushed the fix/ssrf-url-validation-x509util branch from 9b95c1d to b06af35 Compare February 12, 2026 16:25
@mhutchinson
Copy link
Copy Markdown
Contributor

This PR makes the claim that GetIssuer, ReadFileOrURL, and ReadPossiblePEMURL accept URLs that may originate from certificate extension fields (AIA, CDP). These fields are controlled by whoever created the certificate..

I don't see these calls happening in anything other than standalone binaries. Crucially, this is not happening on the serving paths for CT.

Given that the main users of these checks are standalone tools that may be used by testing piplines etc, I think there is a real risk that changing this behaviour breaks someone's workflow. And without a clear information leak risk.

Have I missed something?

@1seal
Copy link
Copy Markdown
Contributor Author

1seal commented Feb 18, 2026

@mhutchinson thanks for the careful review.

you’re right that these callsites are primarily in standalone tools (e.g. ctutil/* and x509util/*check), not the ctfe serving paths, and i’m not claiming impact on the main ct serving components.

for clarity: in this repo ReadFileOrURL is generally fed from flags/config (e.g. log list path), while the certificate-extension-derived inputs are GetIssuer (AIA) and ReadPossiblePEMURL (CDP).

the reason i still think this is worth hardening is that these tools are commonly run in ci / monitoring pipelines on certificates from untrusted sources (ct log submissions and arbitrary tls endpoints). in that setting, AIA/CDP values are attacker-controlled and the process can end up issuing outbound HTTP requests from a privileged network location (ssrf capability), even if the response isn’t intentionally “returned to the attacker”.

concrete callsites in this repo:

sctcheck.go (line 131) calls x509util.GetIssuer when the issuer is missing from the chain (“attempting online retrieval”)
certcheck.go (line 227) calls x509util.ReadPossiblePEMURL on cert.CRLDistributionPoints
crlcheck.go (line 82) reads CRLs via x509util.ReadPossiblePEMURL
on compatibility: the current change only rejects literal loopback/link-local/private IPs in the URL host (hostnames are unchanged), so it should be low-breakage for typical public ca AIA/CDP, while still blocking common footgun targets like 127.0.0.1 and 169.254.169.254. if you’d prefer an even lower-risk default, i can narrow the block to loopback + link-local only (and keep the defer rsp.Body.Close() fixes regardless).

@mhutchinson
Copy link
Copy Markdown
Contributor

Can you rebase this?

I'm not convinced that this fixes a real security issue given my understanding of how these tools are used. That said, given #1775 has come in on the same issue, it's clear that automated scanning will keep bringing this up. In the interests of lowering maintenance burden, I think it's worth accepting this given the low expected downsides. Thanks.

MrINVISO pushed a commit to MrINVISO/certificate-transparency-go that referenced this pull request Mar 16, 2026
PR google#1761 introduced rejectPrivateHost() to block SSRF via
private IP addresses, but only checked net.ParseIP() which
returns nil for hostnames. DNS hostnames resolving to private
or loopback addresses bypassed the fix entirely.

This change resolves hostnames via net.LookupHost() before
IP validation, blocking bypasses such as:
  - localhost → 127.0.0.1 (loopback)
  - metadata.google.internal → 169.254.169.254 (GCP)
  - kubernetes.default.svc → 10.x.x.x (k8s)

Also adds:
  - rejectPrivateHost() to ReadFileOrURL() and GetIssuer()
  - defer rsp.Body.Close() to prevent resource leaks
  - Tests for hostname bypass and SSRF protection

Fixes incomplete fix in PR google#1761 (Issue google#1759)
MrINVISO added a commit to MrINVISO/certificate-transparency-go that referenced this pull request Mar 16, 2026
PR google#1761 introduced rejectPrivateHost() to block SSRF via
private IP addresses, but only checked net.ParseIP() which
returns nil for hostnames. DNS hostnames resolving to private
or loopback addresses bypassed the fix entirely.

This change resolves hostnames via net.LookupHost() before
IP validation, blocking bypasses such as:
  - localhost → 127.0.0.1 (loopback)
  - metadata.google.internal → 169.254.169.254 (GCP)
  - kubernetes.default.svc → 10.x.x.x (k8s)

Also adds:
  - rejectPrivateHost() to ReadFileOrURL() and GetIssuer()
  - defer rsp.Body.Close() to prevent resource leaks
  - Tests for hostname bypass and SSRF protection

Fixes incomplete fix in PR google#1761 (Issue google#1759)
@jub0bs
Copy link
Copy Markdown

jub0bs commented Mar 23, 2026

Would this fix address HTTP redirects from a hostname to a private/loopback IP? What about hostnames with an A/AAAA record to a private/loopback IP address?

1seal added 2 commits March 23, 2026 15:15
GetIssuer, ReadFileOrURL, and ReadPossiblePEMURL fetch URLs that may
originate from attacker-controlled certificate fields (AIA, CDP).
Add private/loopback IP rejection and close response bodies on all
paths to prevent SSRF and resource leaks.
rejectPrivateHost only checked literal IP addresses in the URL.
Two bypasses remained:

1. A hostname resolving to a private/loopback IP (e.g. evil.com → 127.0.0.1)
   was not caught because net.ParseIP returns nil for hostnames.

2. An HTTP redirect from a public host to a private IP was followed by
   the default http.Client without re-checking the target.

Fix: introduce safeTransport (custom DialContext that resolves and
validates all IPs before connecting) and rejectPrivateRedirect
(CheckRedirect hook that blocks redirects to private literal IPs).
All three URL-fetching helpers (ReadPossiblePEMURL, ReadFileOrURL,
GetIssuer) now use safeClient which combines both guards.

Add tests for redirect-to-loopback and localhost-hostname scenarios.
@1seal 1seal force-pushed the fix/ssrf-url-validation-x509util branch from 07aeecd to 13209c4 Compare March 23, 2026 14:15
@1seal
Copy link
Copy Markdown
Contributor Author

1seal commented Mar 23, 2026

@jub0bs good catch — the initial version only checked literal IPs in the URL, so both scenarios you described were indeed bypasses:

  1. hostname → private IP: net.ParseIP returns nil for hostnames, so rejectPrivateHost passed them through.
  2. redirect to loopback: http.Get / client.Get followed redirects without re-checking the target.

just pushed a fix (rebased as well per @mhutchinson's request). the approach:

  • safeTransport: custom DialContext that resolves the hostname via net.DefaultResolver.LookupIPAddr and rejects the connection if any resolved address is loopback, link-local, or private — before the TCP handshake.
  • rejectPrivateRedirect: CheckRedirect hook that calls rejectPrivateHost on each redirect target (catches literal-IP redirects; resolved-IP redirects are caught by the transport).
  • safeClient wraps both into an *http.Client, preserving Timeout/Jar from the caller-supplied client.

all three URL-fetching helpers (ReadPossiblePEMURL, ReadFileOrURL, GetIssuer) now use safeClient. added tests for redirect-to-loopback and localhost-hostname cases.

@jub0bs
Copy link
Copy Markdown

jub0bs commented Mar 23, 2026

@mhutchinson If I were you, I'd be wary of self-described "LLM-augmented" contributions.

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.

3 participants