Skip to content

fix(host): block metadata range under equivalent address encodings - #328

Merged
nedtwigg merged 2 commits into
mainfrom
fix/iframe-ssrf-guard-encodings
Jul 29, 2026
Merged

fix(host): block metadata range under equivalent address encodings#328
nedtwigg merged 2 commits into
mainfrom
fix/iframe-ssrf-guard-encodings

Conversation

@dormouse-bot

@dormouse-bot dormouse-bot commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Harden the iframe-proxy SSRF guard (isBlockedAddress) so it blocks the link-local / cloud-metadata range (169.254.0.0/16) across every address encoding the guard could be handed, not just the literal dotted-decimal string.

Why

The dor-browser spec states this guarantee as a hard security boundary: "link-local/cloud-metadata ranges are blocked," an "SSRF guard that stands regardless of the loosened framing policy."

The previous implementation only tested the raw hostname against /^169\.254\./ and /^fe[89ab]…/. The guard is only ever reached with upstream.hostname from a WHATWG-parsed http: URL, and for special schemes the URL parser already runs the IPv4 canonicalizer — so the numeric spellings (2852039166, 0xa9fea9fe, 0251.0376.0251.0376, 169.254.43518) collapse to 169.254.169.254 before the guard runs and were in fact already blocked end-to-end.

The genuine hole is the IPv6-embedded form. The parser normalizes those to a hex-group spelling the old regex can't see, so they slipped past:

new URL("http://[::ffff:169.254.169.254]/").hostname => "[::ffff:a9fe:a9fe]"
new URL("http://[::169.254.169.254]/").hostname       => "[::a9fe:a9fe]"

So a dor iframe http://[::ffff:169.254.169.254]/… reached the metadata endpoint even though the guard was meant to refuse it — the guard failed its own documented guarantee.

Fix

isBlockedAddress now canonicalizes the host to its 32-bit IPv4 value (inet_aton semantics: decimal/octal/hex, short forms, IPv4-mapped/compatible IPv6) and range-checks 169.254.0.0/16. The fe80::/10 IPv6 link-local check is unchanged. Loopback (127.0.0.1, ::1) and other private ranges stay allowed, matching the spec's "other private ranges are trusted — the boundary is the user's own dor iframe" design.

The IPv4-mapped/compatible extraction closes the real bypass. The numeric-IPv4 canonicalization is kept as defense-in-depth: the guard shouldn't assume its caller pre-normalizes, even though the current sole caller does.

Scope / uncertainty

This closes the encoding bypasses — the same numeric range spelled differently. It does not cover a DNS name that resolves to the metadata IP (e.g. metadata.google.internal) or DNS-rebinding, which would require resolving the host and re-checking the resolved IP at connect time — a larger change against a threat model the spec already frames as the user's own explicit target choice. Left out of scope; noted here so the remaining gap is visible.

Test

Extends the existing isBlockedAddress suite with the encoding cases (the IPv6-embedded ones are newly blocked; the numeric-IPv4 ones assert the defense-in-depth handling) plus negative cases (loopback, private, just-outside-/16). Each IPv6-embedded "blocked" assertion returns false under the old literal-match implementation, so the suite fails without this fix.

Found during the nightly code-quality sweep.

The iframe-proxy SSRF guard only matched the literal 169.254.* string, so
IPv4-mapped IPv6 (::ffff:169.254.169.254), decimal (2852039166), hex/octal,
and short-form spellings of the same 169.254.0.0/16 range reached the
cloud-metadata endpoint. Canonicalize the host to its 32-bit IPv4 value
(inet_aton semantics + IPv4-mapped IPv6) and range-check, matching the
dor-browser spec's stated guarantee that the range is blocked.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 29, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 4c3a960
Status: ✅  Deploy successful!
Preview URL: https://d872e210.mouseterm.pages.dev
Branch Preview URL: https://fix-iframe-ssrf-guard-encodi.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the parsing logic edge-by-edge (inet_aton short forms, octal/hex parts, the last-part overflow bound, and the ::ffff:/:: IPv6 extraction) — the canonicalization is correct and the new suite passes. One thing worth correcting on the record, since this is a security change and the framing sets how severe a maintainer will read it:

The numeric-IPv4 encodings weren't a live bypass; only the IPv6-embedded forms were. isBlockedAddress is only ever reached with upstream.hostname from a WHATWG-parsed http: URL (the caller rejects non-http: first, then passes upstream.hostname). For special schemes the URL parser already runs the IPv4 canonicalizer, so those spellings collapse to dotted-decimal before the guard sees them:

new URL("http://2852039166/").hostname        => "169.254.169.254"
new URL("http://0xa9fea9fe/").hostname         => "169.254.169.254"
new URL("http://0251.0376.0251.0376/").hostname=> "169.254.169.254"
new URL("http://169.254.43518/").hostname      => "169.254.169.254"
new URL("http://0xA9.0xFE.0xA9.0xFE/").hostname=> "169.254.169.254"

So the old /^169\.254\./ regex did block all of those end-to-end. What it missed is the IPv6 wrapping, because the parser normalizes those to a hex-group form the regex can't see:

new URL("http://[::ffff:169.254.169.254]/").hostname => "[::ffff:a9fe:a9fe]"
new URL("http://[::169.254.169.254]/").hostname       => "[::a9fe:a9fe]"

That's the genuine hole this closes. The numeric-IPv4 handling is still worth keeping as defense-in-depth — the guard shouldn't assume its caller pre-normalizes — but the PR body's "http://2852039166/… reached the metadata endpoint" isn't accurate for the real call path, and the test comment "Alternate encodings the OS resolver treats as 169.254.169.254" is really "encodings the guard must handle without relying on the URL parser." Toning that down keeps the severity honest.

The out-of-scope note on DNS-name / DNS-rebind resolution is the right call and is the actual remaining gap — good that it's flagged rather than silently omitted.

(Self-authored, so posting as a comment rather than an approval.)

The numeric-IPv4 encodings are collapsed to dotted-decimal by the WHATWG
URL parser before the guard sees them (the guard only ever receives an
http: upstream.hostname), so the old 169.254.* regex already blocked them
end-to-end. The genuine hole this fix closes is the IPv6-embedded form
(::ffff:169.254.169.254), which the parser normalizes to a hex-group
spelling the regex can't match. Retitle the numeric handling as
defense-in-depth rather than a live-bypass fix.
@dormouse-bot

Copy link
Copy Markdown
Collaborator Author

Good catch on the record — verified it: new URL("http://2852039166/").hostname (and the octal/hex/short-form spellings) all canonicalize to 169.254.169.254 before the guard runs, since the guard only ever sees upstream.hostname from a WHATWG-parsed http: URL. So the old regex did block those end-to-end; the only genuine hole was the IPv6-embedded form ([::ffff:169.254.169.254][::ffff:a9fe:a9fe]).

Toned the framing down accordingly in 4c3a960:

  • PR body now frames the numeric-IPv4 handling as defense-in-depth and names the IPv6-embedded form as the real bypass.
  • The test comment "Alternate encodings the OS resolver treats as 169.254.169.254" is rewritten to note the URL parser already collapses those, so the guard must not rely on that pre-normalization.
  • The source comment on isBlockedAddress gets the same correction.

Code (the canonicalization) is unchanged — keeping it as defense-in-depth so the guard doesn't assume its caller pre-normalizes. The DNS-name / DNS-rebind gap stays out of scope.

@nedtwigg
nedtwigg merged commit 8eb6385 into main Jul 29, 2026
7 checks passed
@nedtwigg
nedtwigg deleted the fix/iframe-ssrf-guard-encodings branch July 29, 2026 14:36
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