Skip to content

fix(ai): close WHATWG canonicalisation bypass in localOnlyFetch (sec)#542

Open
sroussey wants to merge 1 commit into
mainfrom
claude/beautiful-mayer-Y5xRl
Open

fix(ai): close WHATWG canonicalisation bypass in localOnlyFetch (sec)#542
sroussey wants to merge 1 commit into
mainfrom
claude/beautiful-mayer-Y5xRl

Conversation

@sroussey
Copy link
Copy Markdown
Collaborator

Summary

localOnlyFetch's initial-URL gate validated new URL(input).hostname against isLoopbackHostname. The WHATWG URL parser silently rewrites non-standard IPv4 spellings to canonical dotted-quads, so any of the following inputs were silently accepted as loopback before this fix:

  • http://0x7f.0.0.1/ — hex-octet IPv4 rewritten to 127.0.0.1
  • http://2130706433/ — uint32 IPv4 rewritten to 127.0.0.1
  • http://010.0.0.1/ — lenient octal-looking IPv4 rewritten (runtime-dependent) to 127.0.0.1 (or 10.0.0.1)

That's an SSRF bypass for any caller wiring localOnlyFetch against a user-supplied URL. Sibling normalizeLocalHttpUrl already closed the same bypass class in PR #533 by validating extractRawHost(rawUrl) — this PR applies the same fix to localOnlyFetch.

The fix

  • Import extractRawHost alongside isLoopbackHostname from ./localUrl.
  • assertLoopbackTarget grows an optional rawHost?: string | null parameter. When supplied (initial URL), it is used (after stripping []) for the isLoopbackHostname literal check instead of url.hostname; when omitted (redirect target), behaviour is unchanged.
  • At the initial-URL call site, extractRawHost(input) is computed BEFORE any network call; a null result throws ${label}: invalid initial URL., otherwise the raw host is passed through to assertLoopbackTarget.
  • The redirect-side call (assertLoopbackTarget(next, label, "redirect")) is intentionally unchanged: the raw form is not available for redirect targets, and the canonical-hostname check there is still a tightening. (The all-prior gating that led to the redirect — the same loopback check on the previous hop — is unaffected.)

Regression cases added

packages/test/src/test/ai-provider-api/localOnlyFetch.test.ts gains three new tests, one per IPv4 spelling, each mirroring the existing "rejects a non-loopback initial URL before issuing any fetch" shape:

  • http://0x7f.0.0.1/ rejects with /non-loopback host|invalid initial URL/ AND issues zero fetches.
  • http://2130706433/ rejects with the same pattern AND issues zero fetches.
  • http://010.0.0.1/ rejects with the same pattern AND issues zero fetches.

The regex matches either of the two valid rejection messages: non-loopback host (the expected path on every modern WHATWG runtime, where the raw host fails isLoopbackHostname's strict grammar) or invalid initial URL (defensive fallback if extractRawHost returns null on some exotic input).

Test plan

  • Run packages/test vitest suite locally / in CI and confirm all localOnlyFetch tests pass (existing 13 + 3 new = 16).
  • Confirm no other call sites of assertLoopbackTarget exist in the package (it's file-private).
  • Confirm extractRawHost is re-exported from ./localUrl (already used by normalizeLocalHttpUrl in the same package).

https://claude.ai/code/session_014tiKE9KxsLy9rdhJk8qjmh


Generated by Claude Code

…al-URL check (sec)

`assertLoopbackTarget` validated `new URL(input).hostname` for the initial URL,
which WHATWG canonicalises so `0x7f.0.0.1`, `2130706433`, and lenient `010.0.0.1`
all silently rewrite to `127.0.0.1`. Sibling `normalizeLocalHttpUrl` already closed
the same bypass in PR #533 by validating `extractRawHost(rawUrl)`; apply the same
fix here so any caller wiring `localOnlyFetch` against a user-supplied URL cannot
regress. Redirect targets stay on canonical hostname (no raw form available, and
the canonical check is still a tightening).

Regression tests in localOnlyFetch.test.ts cover all three IPv4 spellings and
assert zero fetches are made.
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