fix(host): block metadata range under equivalent address encodings - #328
Conversation
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.
Deploying mouseterm with
|
| 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 |
dormouse-bot
left a comment
There was a problem hiding this comment.
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.
|
Good catch on the record — verified it: Toned the framing down accordingly in
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. |
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 withupstream.hostnamefrom a WHATWG-parsedhttp: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 to169.254.169.254before 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:
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
isBlockedAddressnow canonicalizes the host to its 32-bit IPv4 value (inet_aton semantics: decimal/octal/hex, short forms, IPv4-mapped/compatible IPv6) and range-checks169.254.0.0/16. Thefe80::/10IPv6 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 owndor 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
isBlockedAddresssuite 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 returnsfalseunder the old literal-match implementation, so the suite fails without this fix.Found during the nightly code-quality sweep.