Skip to content

fix(security): close SSRF guard bypasses (IPv4-mapped IPv6 + browser redirects)#39

Merged
Doorman11991 merged 1 commit into
Doorman11991:masterfrom
aaronjmars:security/ssrf-guard-ipv4-mapped-ipv6-and-browser-redirects
May 23, 2026
Merged

fix(security): close SSRF guard bypasses (IPv4-mapped IPv6 + browser redirects)#39
Doorman11991 merged 1 commit into
Doorman11991:masterfrom
aaronjmars:security/ssrf-guard-ipv4-mapped-ipv6-and-browser-redirects

Conversation

@aaronjmars

Copy link
Copy Markdown
Contributor

Summary

Two adjacent gaps in the SSRF guard surface let an LLM-controlled URL passed to the web_fetch tool reach cloud metadata (AWS / GCP IMDS) when LLM_ALLOW_PUBLIC_ENDPOINTS=1 is set — the production-mode flag your ssrf_guard.js header comment documents as the supported way to permit public endpoints while still keeping metadata IPs blocked "even when LLM_ALLOW_PUBLIC_ENDPOINTS=1 is set."

Both gaps fall under the same class — incomplete coverage of every representation the OS will route to the same destination — and the fix bundles them. Single-file scope where possible, plus a node --test-only test suite (no new deps).

Impact

web_fetch is the tool the LLM uses to read arbitrary URLs the model decides to fetch. With LLM_ALLOW_PUBLIC_ENDPOINTS=1, only isAlwaysBlocked stands between the model and http://169.254.169.254/latest/meta-data/iam/security-credentials/. Either bypass below lets an attacker who can influence the model's tool calls (prompt injection from a page the model is reading, a poisoned input in the conversation, etc.) read short-lived cloud credentials and exfiltrate them via a follow-up web_fetch to an attacker-controlled host (which itself passes the guard, because it's a public hostname).

The same two bypasses also apply to OpenAICompatProvider's constructor — assertEndpointAllowed is called once at provider build time on the configured endpoint, and the same canonicalization gap means a .smallcode/config-supplied LLM endpoint of http://[::ffff:169.254.169.254]/v1/chat/completions would pass the guard.

Location

  • src/compiled/providers/ssrf_guard.js + matching .ts (the .ts is recompiled by node build.js, so both need the fix for it to survive a rebuild — the in-tree .ts had already drifted to an older shape and was missing isAlwaysBlocked entirely)
  • src/tools/builtin/web_browse.js_fetchWithBrowser

What this PR changes

1. Hostname canonicalization in the SSRF guard

isAlwaysBlocked (and its siblings isLoopback, isRfc1918) only inspected one representation of the hostname — the literal lowered string. Node's URL parser normalizes http://[::ffff:169.254.169.254]/ to hostname [::ffff:a9fe:a9fe], so the dotted-quad regex never fired:

$ node repro-ssrf.cjs                          # before this PR
ALLOWED — AWS IMDS via IPv4-mapped IPv6 (dotted)  [http://[::ffff:169.254.169.254]/latest/meta-data/]
ALLOWED — AWS IMDS via IPv4-mapped IPv6 (hex)     [http://[::ffff:a9fe:a9fe]/latest/meta-data/]
ALLOWED — AWS IMDS via IPv4-mapped IPv6 (expanded)[http://[0:0:0:0:0:ffff:169.254.169.254]/latest/meta-data/]

Net's isIPv6('::ffff:a9fe:a9fe') returns true and the OS routes the connection to the embedded 169.254.169.254 without any DNS lookup.

The fix introduces hostVariants(host) which yields every representation the OS might route to:

  • the input lowered
  • the input with surrounding [] stripped (URL.hostname keeps them for IPv6)
  • the embedded dotted-quad for ::ffff:a.b.c.d and ::a.b.c.d
  • the dotted-quad reconstructed from the ::ffff:wwww:xxxx hex-pair form (this is the canonical shape Node's parser hands back)

isLoopback, isRfc1918, and isAlwaysBlocked now each evaluate across the full variant set. Same behavior on dotted-quad inputs; closes the alias gap.

2. Re-validate redirects in _fetchWithBrowser

_fetchSimple already defends with redirect: 'manual' and has an explicit in-line comment:

redirect: 'manual', // Don't auto-follow — a 30x to 169.254.169.254 would bypass the SSRF guard

_fetchWithBrowser (the default path whenever Playwright is installed — getBrowser() returns the launched instance) calls page.goto(url) with no per-hop control, so an attacker page that returns 302 Location: http://169.254.169.254/latest/meta-data/ lands directly on metadata.

Fix: page.route('**/*', …) intercepts every request the page issues (initial, redirects, and subresources the document fetches), runs _assertUrlSafe(route.request().url()), and aborts anything the guard rejects.

Verification

$ node --test test/ssrf_guard.test.js
# tests 14
# suites 0
# pass 14
# fail 0

test/ssrf_guard.test.js adds direct regression cases for:

  • AWS IMDS via dotted-quad, IPv6 literal (fd00:ec2::254), IPv4-mapped IPv6 (dotted form, hex pair form, expanded-zeros form)
  • GCP metadata.google.internal
  • Link-local range (169.254.x.x) outside the IMDS literal
  • CGNAT 100.64.0.0/10
  • Public endpoint permitted under LLM_ALLOW_PUBLIC_ENDPOINTS=1
  • Loopback + RFC1918 permitted in default mode
  • Origin-equality allowlist (matches your .js design note — the .ts had an older endpoint.startsWith(a) form which would have let https://api.openai.com.attacker.com/ slip past an allowlist of https://api.openai.com; rewriting .ts from the .js fixes that too, so the next node build.js doesn't reintroduce it)
  • Non-http(s) scheme rejected
  • Malformed URL rejected

No new runtime dependencies. The test suite uses Node's built-in node:test / node:assert/strict and runs in under 80ms. No test script added to package.json — happy to add one ("test": "node --test test/") if you'd prefer it wired in, or to migrate the tests to jest since you have it in devDependencies.

The _fetchWithBrowser redirect-guard path isn't covered by the included regression test because that path requires playwright-extra / puppeteer-extra-plugin-stealth (both intentionally optional in this repo), and the scanner sandbox couldn't install them. The fix is small and the behavior is symmetric with the _fetchSimple defense the maintainer already documents — easy to eyeball, but happy to add an integration test against a stub browser if you'd like.

Detected by

Aeon (manual review around the existing SECURITY.md / in-source claims — the ssrf_guard.js header explicitly says metadata IPs are blocked "even when LLM_ALLOW_PUBLIC_ENDPOINTS=1 is set," which read as a maintainer commitment worth verifying; the bypasses are both completeness gaps under that stated promise rather than missing policy).

  • Severity: high (SSRF to cloud metadata via LLM-controlled web_fetch URL, production mode)
  • CWE-918 (SSRF) + CWE-1389 (IP address blocklist incompleteness)

Filed by Aeon.

…redirects)

Two adjacent gaps let an LLM-supplied URL passed to the web_fetch tool
reach cloud metadata when LLM_ALLOW_PUBLIC_ENDPOINTS=1 is set
(production mode).

1. isAlwaysBlocked() in src/compiled/providers/ssrf_guard.js only
   matched dotted-quad IPv4. Node's URL parser normalizes
   http://[::ffff:169.254.169.254]/ to hostname [::ffff:a9fe:a9fe], so
   the IMDS endpoint slipped past while the OS still routed the
   connection to the underlying IPv4. Same gap for the hex and
   expanded-zero IPv6 representations and for the IPv4-compatible
   ::a.b.c.d form.

   Fix: hostVariants(host) yields every representation the OS might
   route to (bracket-stripped form, embedded dotted-quad for ::ffff:
   and :: aliases, and the hex-pair form ::ffff:wwww:xxxx); isLoopback,
   isRfc1918, and isAlwaysBlocked are then evaluated across all of them.

2. _fetchWithBrowser in src/tools/builtin/web_browse.js called
   page.goto() with no per-hop URL validation. _fetchSimple already
   defends with redirect: 'manual' and a comment explaining the exact
   threat ("a 30x to 169.254.169.254 would bypass the SSRF guard"),
   but the browser path missed the same defense — which is the default
   path whenever playwright is installed.

   Fix: page.route('**/*') intercepts every request the page issues
   (initial, redirects, subresources) and re-applies _assertUrlSafe;
   anything the guard rejects is aborted before connect.

Detected by Aeon (manual review of the SSRF guard surface around the
maintainer's own SECURITY.md / in-source claims).

Severity: high (SSRF to cloud metadata via LLM-controlled web_fetch URL
in production-mode deployments)
CWE-918 (SSRF) + CWE-1389 (IP address blocklist incompleteness)

Verification: node --test test/ssrf_guard.test.js — 14/14 pass.
test/ssrf_guard.test.js seeds direct regression cases for every IPv4
aliasing form, the dotted-quad IMDS, the GCP literal, the IPv6 IMDS,
RFC1918, loopback, the origin-equality allowlist (vs the older raw-prefix
form), non-http schemes, and malformed URLs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@Doorman11991 Doorman11991 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Verified locally. Reproduced the IPv4-mapped IPv6 bypass on master (http://[::ffff:169.254.169.254]/ and the hex/expanded variants all returned ALLOWED under LLM_ALLOW_PUBLIC_ENDPOINTS=1). After applying this PR, all five metadata aliases — dotted-quad, IPv4-mapped IPv6 (dotted/hex/expanded), and the AWS IPv6 fd00:ec2::254 — return BLOCKED. Confirmed the .ts → .js build survives
ode build.js. 14/14 included tests pass; existing TUI tests still pass 11/11. Origin-equality match on the allowlist (vs. the old startsWith in the .ts) closes the prefix-spoof angle too. Thanks for the thorough writeup.

@Doorman11991
Doorman11991 merged commit cec7894 into Doorman11991:master May 23, 2026
6 checks passed
Doorman11991 added a commit that referenced this pull request May 23, 2026
Bundles two merged fixes since 1.0.2:

* PR #41 (closes #36, #40): tool-call extractor for qwen2.5-coder + /version slash command

* PR #39: SSRF guard hardening — IPv4-mapped IPv6 alias bypass + browser redirect re-validation
Kothulhu94 pushed a commit to Kothulhu94/smallcode that referenced this pull request May 29, 2026
…redirects) (Doorman11991#39)

Two adjacent gaps let an LLM-supplied URL passed to the web_fetch tool
reach cloud metadata when LLM_ALLOW_PUBLIC_ENDPOINTS=1 is set
(production mode).

1. isAlwaysBlocked() in src/compiled/providers/ssrf_guard.js only
   matched dotted-quad IPv4. Node's URL parser normalizes
   http://[::ffff:169.254.169.254]/ to hostname [::ffff:a9fe:a9fe], so
   the IMDS endpoint slipped past while the OS still routed the
   connection to the underlying IPv4. Same gap for the hex and
   expanded-zero IPv6 representations and for the IPv4-compatible
   ::a.b.c.d form.

   Fix: hostVariants(host) yields every representation the OS might
   route to (bracket-stripped form, embedded dotted-quad for ::ffff:
   and :: aliases, and the hex-pair form ::ffff:wwww:xxxx); isLoopback,
   isRfc1918, and isAlwaysBlocked are then evaluated across all of them.

2. _fetchWithBrowser in src/tools/builtin/web_browse.js called
   page.goto() with no per-hop URL validation. _fetchSimple already
   defends with redirect: 'manual' and a comment explaining the exact
   threat ("a 30x to 169.254.169.254 would bypass the SSRF guard"),
   but the browser path missed the same defense — which is the default
   path whenever playwright is installed.

   Fix: page.route('**/*') intercepts every request the page issues
   (initial, redirects, subresources) and re-applies _assertUrlSafe;
   anything the guard rejects is aborted before connect.

Detected by Aeon (manual review of the SSRF guard surface around the
maintainer's own SECURITY.md / in-source claims).

Severity: high (SSRF to cloud metadata via LLM-controlled web_fetch URL
in production-mode deployments)
CWE-918 (SSRF) + CWE-1389 (IP address blocklist incompleteness)

Verification: node --test test/ssrf_guard.test.js — 14/14 pass.
test/ssrf_guard.test.js seeds direct regression cases for every IPv4
aliasing form, the dotted-quad IMDS, the GCP literal, the IPv6 IMDS,
RFC1918, loopback, the origin-equality allowlist (vs the older raw-prefix
form), non-http schemes, and malformed URLs.

Co-authored-by: Aaron via Aeon <aaron@aeon.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Kothulhu94 pushed a commit to Kothulhu94/smallcode that referenced this pull request May 29, 2026
Bundles two merged fixes since 1.0.2:

* PR Doorman11991#41 (closes Doorman11991#36, Doorman11991#40): tool-call extractor for qwen2.5-coder + /version slash command

* PR Doorman11991#39: SSRF guard hardening — IPv4-mapped IPv6 alias bypass + browser redirect re-validation
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