fix(security): close SSRF guard bypasses (IPv4-mapped IPv6 + browser redirects)#39
Merged
Doorman11991 merged 1 commit intoMay 23, 2026
Conversation
…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
approved these changes
May 23, 2026
Doorman11991
left a comment
Owner
There was a problem hiding this comment.
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
added a commit
that referenced
this pull request
May 23, 2026
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two adjacent gaps in the SSRF guard surface let an LLM-controlled URL passed to the
web_fetchtool reach cloud metadata (AWS / GCP IMDS) whenLLM_ALLOW_PUBLIC_ENDPOINTS=1is set — the production-mode flag yourssrf_guard.jsheader comment documents as the supported way to permit public endpoints while still keeping metadata IPs blocked "even whenLLM_ALLOW_PUBLIC_ENDPOINTS=1is 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_fetchis the tool the LLM uses to read arbitrary URLs the model decides to fetch. WithLLM_ALLOW_PUBLIC_ENDPOINTS=1, onlyisAlwaysBlockedstands between the model andhttp://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-upweb_fetchto 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 —assertEndpointAllowedis called once at provider build time on the configured endpoint, and the same canonicalization gap means a.smallcode/config-supplied LLM endpoint ofhttp://[::ffff:169.254.169.254]/v1/chat/completionswould pass the guard.Location
src/compiled/providers/ssrf_guard.js+ matching.ts(the.tsis recompiled bynode build.js, so both need the fix for it to survive a rebuild — the in-tree.tshad already drifted to an older shape and was missingisAlwaysBlockedentirely)src/tools/builtin/web_browse.js—_fetchWithBrowserWhat this PR changes
1. Hostname canonicalization in the SSRF guard
isAlwaysBlocked(and its siblingsisLoopback,isRfc1918) only inspected one representation of the hostname — the literal lowered string. Node's URL parser normalizeshttp://[::ffff:169.254.169.254]/to hostname[::ffff:a9fe:a9fe], so the dotted-quad regex never fired:Net's
isIPv6('::ffff:a9fe:a9fe')returnstrueand the OS routes the connection to the embedded169.254.169.254without any DNS lookup.The fix introduces
hostVariants(host)which yields every representation the OS might route to:[]stripped (URL.hostnamekeeps them for IPv6)::ffff:a.b.c.dand::a.b.c.d::ffff:wwww:xxxxhex-pair form (this is the canonical shape Node's parser hands back)isLoopback,isRfc1918, andisAlwaysBlockednow each evaluate across the full variant set. Same behavior on dotted-quad inputs; closes the alias gap.2. Re-validate redirects in
_fetchWithBrowser_fetchSimplealready defends withredirect: 'manual'and has an explicit in-line comment:_fetchWithBrowser(the default path whenever Playwright is installed —getBrowser()returns the launched instance) callspage.goto(url)with no per-hop control, so an attacker page that returns302 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
test/ssrf_guard.test.jsadds direct regression cases for:fd00:ec2::254), IPv4-mapped IPv6 (dotted form, hex pair form, expanded-zeros form)metadata.google.internal169.254.x.x) outside the IMDS literal100.64.0.0/10LLM_ALLOW_PUBLIC_ENDPOINTS=1.jsdesign note — the.tshad an olderendpoint.startsWith(a)form which would have lethttps://api.openai.com.attacker.com/slip past an allowlist ofhttps://api.openai.com; rewriting.tsfrom the.jsfixes that too, so the nextnode build.jsdoesn't reintroduce it)No new runtime dependencies. The test suite uses Node's built-in
node:test/node:assert/strictand runs in under 80ms. Notestscript added topackage.json— happy to add one ("test": "node --test test/") if you'd prefer it wired in, or to migrate the tests tojestsince you have it indevDependencies.The
_fetchWithBrowserredirect-guard path isn't covered by the included regression test because that path requiresplaywright-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_fetchSimpledefense 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.jsheader explicitly says metadata IPs are blocked "even whenLLM_ALLOW_PUBLIC_ENDPOINTS=1is set," which read as a maintainer commitment worth verifying; the bypasses are both completeness gaps under that stated promise rather than missing policy).web_fetchURL, production mode)Filed by Aeon.