Skip to content

fix: harden IPv6 address parsing in URL safety check - #233

Draft
Valiunia wants to merge 9 commits into
mainfrom
harden-url-safety-ipv6-encodings
Draft

fix: harden IPv6 address parsing in URL safety check#233
Valiunia wants to merge 9 commits into
mainfrom
harden-url-safety-ipv6-encodings

Conversation

@Valiunia

@Valiunia Valiunia commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

isSafeExternalUrl() validates custom-marker overlay URLs before they're forwarded for server-side fetching, blocking hosts in loopback/private/link-local/internal ranges. The original implementation matched a handful of known string shapes for IPv4-in-IPv6 addresses (dotted-quad, ::ffff: hex form) and a short hostname blocklist, which missed other standard encodings and hostname vectors — bare IPv4-compatible (::a.b.c.d), 6to4, NAT64, SIIT, and internal DNS hostnames like metadata.google.internal.

Change

  • Replace IPv4/IPv6 pattern-matching with ipaddr.js, deny-by-default: both address families are allowed only when classified as plain global "unicast." Every other named range (loopback, private, link-local, multicast, broadcast, CGNAT, reserved, and every IPv4-in-IPv6 transition/translation mechanism — mapped, compatible, 6to4, NAT64, SIIT, Teredo, AMT, ORCHID) is rejected outright, without needing to identify or check an embedded IPv4 address — coverage no longer depends on enumerating every encoding by hand.
  • Extend the hostname blocklist with reserved local/internal-use suffixes (.internal, .local, .arpa) and reject dotless single-label hostnames, closing hostname-based SSRF vectors (e.g. metadata.google.internal, the actual GCP metadata endpoint hostname) that the IP-literal-focused check never covered.
  • Fix a hostname-normalization gap where a trailing root-label dot (localhost.) bypassed the local-hostname blocklist.
  • Forward the canonical WHATWG re-serialization of a validated custom-marker URL instead of the caller's original raw string, so a downstream parser can't disagree with the parser that validated it (e.g. a backslash before @, which WHATWG treats as a path separator for special schemes but a more lenient parser could read as a userinfo separator).
  • Escape ( and ) in overlay values (custom-marker URL, GeoJSON payload) before embedding them in the encoded overlay string — encodeURIComponent leaves these unescaped, and they delimit overlay segments in the Static Images API's own syntax, so an unescaped ) in a value could terminate that segment early.

Test plan

  • npx vitest run — 771 tests pass
  • npx tsc --noEmit — no errors
  • npx eslint — no issues
  • Test coverage for every IPv4-in-IPv6 encoding (mapped, compatible, 6to4, NAT64 incl. the well-known alternate /48 prefix, SIIT), hostname suffixes, dotless hostnames, canonicalization, and overlay-delimiter escaping

Note on output changes

Custom-marker URLs and GeoJSON overlay payloads containing ( ) ! ' * will now have those characters percent-encoded in the request sent to the Static Images API (previously passed through unescaped). The API decodes percent-encoding, so this is semantically equivalent, but the literal request text differs from what a previous version of this tool would have produced. Encoded polylines are unaffected — the standard polyline alphabet (ASCII 63-126) never includes these characters.

🤖 Generated with Claude Code

isSafeExternalUrl() blocks IP-literal hosts in loopback/private/link-local
ranges before a custom-marker overlay URL is forwarded for server-side
fetching. The IPv6 branch matched a handful of known string shapes for
IPv4-in-IPv6 addresses (dotted-quad, ::ffff: hex form), which missed other
standard encodings — bare IPv4-compatible, 6to4, and NAT64 — letting
blocked IPv4 ranges through when expressed in one of those forms.

Replace the pattern-matching with ipaddr.js, which parses the full address
and classifies its range directly, and extract the embedded IPv4 address
for every known encoding so it gets checked against the same blocklist as
a plain IPv4 literal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Valiunia
Valiunia requested a review from a team as a code owner July 29, 2026 12:15
@Valiunia
Valiunia marked this pull request as draft July 29, 2026 12:23
Valiunia and others added 8 commits July 29, 2026 13:27
The previous pass enumerated ipaddr.js range names (ipv4Mapped, rfc6052,
6to4) to find where to check for an embedded IPv4 address, which missed
the IPv4-translated/SIIT form (::ffff:0:a.b.c.d, RFC 6145) since ipaddr.js
classifies it as its own range name rather than ipv4Mapped.

Detect the embedding from the address's raw byte structure instead: 6to4
and NAT64 keep their explicit range checks (their prefixes aren't zero),
but IPv4-compatible, IPv4-mapped, and IPv4-translated are now recognized
together as a single case — leading 64 bits zero, followed by one of the
three IETF-defined 32-bit separators before the embedded address.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- NAT64 well-known alternate prefix (64:ff9b:1::/48)
- case-insensitivity of the embedding detection
- a public IPv6 address whose low bits happen to look like a blocked
  IPv4 address, but isn't a recognized embedding shape, stays allowed
- document current behavior for Teredo (2001::/32), whose embedded
  IPv4 is XOR-obfuscated rather than directly readable from the address
  bytes, and is not handled by this check

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…embeddings

Enumerating individual IPv4-in-IPv6 embedding schemes (mapped, compatible,
6to4, NAT64, SIIT) kept missing cases: a /48 NAT64 encoding that places
the embedded address at hextet boundaries instead of the RFC-specified
split around a reserved byte slipped past the byte-offset assumptions
used to extract it.

Switch to deny-by-default: any IPv6 address outside the plain global
unicast range is now rejected outright, rather than decoding the embedded
IPv4 for each transition/translation mechanism and checking it against
the IPv4 blocklist. This also covers Teredo, whose embedded address is
XOR-obfuscated and was previously unblockable by design, and mechanisms
not embedding a blocked address at all — legitimate marker hosts have no
reason to be addressed through any of them.

One residual case needs an explicit check: ipaddr.js does not classify
the deprecated IPv4-compatible form (::a.b.c.d) as special when written
in hex, which is exactly the form Node's URL parser normalises a
dotted-quad literal to.

Also fixes a related hostname-normalization gap: "localhost." (with a
trailing root-label dot, DNS-equivalent to "localhost") was not matched
by the local-hostname blocklist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The IPv4 branch still used a hand-written regex plus a manually
maintained list of "dangerous" octet ranges, unlike the IPv6 branch's
deny-by-default approach. Route it through the same ipaddr.js range
classification instead: allow only 'unicast', reject everything else.

This closes gaps the hand-written list never covered — broadcast
(255.255.255.255) and the RFC 5737 documentation/TEST-NET ranges
(192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) were never blocked
before. It also removes ~15 lines of manually maintained range logic
in favor of one range check shared with the IPv6 path.

Add regression tests for the newly-covered ranges, for IPv4 literals
written in shorthand/hex/octal/decimal-integer notation (Node's URL
parser already canonicalizes these before this function sees them,
confirmed end-to-end), and for malformed out-of-range octets (rejected
by the URL constructor itself, never reaching the fallback hostname
path).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The custom-marker url field was validated with isSafeExternalUrl (which
parses it via the WHATWG URL class) but then forwarded to the Static
Images API as the caller's original raw string, just percent-encoded for
transport. The receiving service decodes that back to the identical
bytes and parses them again, independently, with its own URL parser.

If a different parser disagrees with WHATWG on how to interpret some
raw string (e.g. a backslash before an "@", which WHATWG treats as a
path separator for special schemes but a more lenient parser could read
as a userinfo separator), the host that was actually validated is not
guaranteed to be the host that gets connected to downstream.

Re-serialize the URL to its canonical WHATWG form after validation and
forward that instead of the caller's raw string, so any downstream
parser is working from an unambiguous, already-normalized string rather
than re-parsing arbitrary input independently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
IP-literal handling was deny-by-default and near-exhaustive, but the
hostname side only blocked four exact names plus a .localhost suffix
— missing the more likely real-world vector, since cloud metadata
services are commonly reached by hostname rather than IP literal.

Add:
  - a suffix blocklist for reserved local/internal-use DNS zones:
    .internal (GCP/AWS service discovery — covers
    metadata.google.internal, the actual GCP metadata hostname),
    .local (mDNS), and .arpa (reverse-DNS and RFC 8375 home-network
    zones, covering .in-addr.arpa, .ip6.arpa, and .home.arpa as
    suffixes of it)
  - rejection of dotless single-label hostnames (e.g. "metadata",
    "intranet"), which only resolve via the fetcher's own network
    search-domain suffixing and are never a legitimate public host

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…onent

encodeURIComponent does not escape ( ) ! ' * (they're valid in a URI
component per RFC 3986's "unreserved" set, a holdover from the older
escape() behaviour). Every overlay value that gets embedded inside a
parenthesis-delimited segment of the encoded overlay string — the
custom-marker URL, the polyline, and the GeoJSON payload — could
therefore contain a bare ")" that terminates that segment early from
the Static Images API's own overlay-syntax parser's point of view, even
though the value already passed URL/JSON validation on our side. Same
class of bug as the URL-parser-differential issue this PR already
addresses, one layer up.

Add encodeOverlayComponent(), which escapes those five characters on
top of encodeURIComponent, and use it for all three overlay value types.

Also document two related, deliberately out-of-scope items in the
urlSafety module comment: port restriction (belongs in the upstream
fetcher, not this pre-fetch check) and the inherent incompleteness of
enumerating local-network hostname suffixes (.corp, .lan, .home, etc.
are network-specific and not enumerable in general) — both are the same
class of problem as the already-documented DNS-rebinding limitation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The module comment had accumulated into a record of this PR's internal
back-and-forth (re-arguing deny-by-default vs. the previous approach)
rather than documentation for someone new to the file. Cut it down to
the durable claims: what's rejected and why the three out-of-scope
items (DNS rebinding, port restriction, non-enumerable local suffixes)
are out of scope, without re-litigating already-superseded approaches.

Also add a short comment noting that the dotless-hostname check depends
on the trailing-dot strip running first — the two lines are coupled but
were previously unlinked, which could trip up a future edit that
reorders them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

1 participant