Skip to content

fix(security): judge outbound hosts by address, not by spelling - #10843

Merged
diegosouzapw merged 2 commits into
diegosouzapw:release/v3.8.50from
ntdat812:fix/outbound-guard-mapped-ipv4
Aug 20, 2026
Merged

diegosouzapw merged 2 commits into
diegosouzapw:release/v3.8.50from
ntdat812:fix/outbound-guard-mapped-ipv4

Conversation

@ntdat812

@ntdat812 ntdat812 commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

isCloudMetadataHost() decides whether a host is a cloud-metadata endpoint by matching its
dotted-decimal spelling, so an IPv4-mapped IPv6 literal that routes to the same address is not
recognised. http://[::ffff:169.254.169.254]/ reaches the guard as ::ffff:a9fe:a9fe and passes a
check documented as absolute. This makes the verdict follow the address instead of its spelling.

new URL() canonicalises the embedded quad to hextets before any guard sees it:

new URL("http://[::ffff:169.254.169.254]/").hostname  // "[::ffff:a9fe:a9fe]"
new URL("http://[::ffff:100.100.100.200]/").hostname  // "[::ffff:6464:64c8]"

isPrivateHost() is unaffected — it refuses the whole ::ffff: prefix. The exposure is in the two
guard modes that deliberately skip the private-host check and rely on the metadata block alone:

  • parseAndValidateNonMetadataUrl() — selected by getProviderValidationGuard() whenever
    areLocalProviderUrlsAllowed() is true, which is the local-first default. It intentionally
    permits private/LAN provider endpoints, so the metadata block is the only control left.
  • parseAndValidateWebhookUrl() — documents metadata as blocked "even when the private opt-in is
    enabled".

assertSafeCatalogUrl() and isNoAuthLocalEmbeddingHost() read the same helper; in the latter a
mapped metadata host is classified as a no-auth local provider.

Fix: mappedIpv4Host() folds the embedded IPv4 out of a ::ffff: literal — accepting both the
dotted form (raw hosts) and the hextet form (what new URL() yields) — and isCloudMetadataHost()
applies the existing IPv4 rules to it. Also refuses :: in isPrivateHost(): 0.0.0.0 was already
refused, but its IPv6 twin was not, and connecting to [::] reaches a service bound to the IPv6
loopback. No call sites change; both helpers keep their signatures.

Reported privately first via GitHub Security Advisories (GHSA-qcfj-c39q-88jh); opening the fix as a
PR at the maintainer's discretion — happy to move it back behind a private fork if preferred.

Related Issues

  • Related to GHSA-qcfj-c39q-88jh (private advisory, this repo)

Validation

  • Change type: other (shared outbound-URL security guard)
  • Focused tests and category gates from the golden path
  • npm run lint
  • Reconciled with the current active release base (release/v3.8.50 @ bc6129bc); focused checks rerun afterward
  • Production-code changes include a new automated test in this PR

Commands run locally (Windows, Node 22):

npx tsx --test tests/unit/outbound-guard-mapped-ipv4.test.ts              12/12 pass
  + webhook-ssrf-guard, firecrawl-search-ssrf-guard, kiro-region-ssrf-guard,
    provider-validation-ssrf-guard, api/webhooks/webhook-url-ssrf-guard    73/73 pass total
npx tsx --test tests/unit/security/** tests/unit/shared/**                93/94
npx eslint src/shared/network/outboundUrlGuard.ts tests/unit/outbound-guard-mapped-ipv4.test.ts
                                                                          clean
npm run check:changelog-integrity                                         OK
npm run build                                                             exit 1 — pre-existing, see comment

The one failure in security/** + shared/** (error() with a destroyed stderr does not crash …)
reproduces identically on a clean release/v3.8.50 checkout, as does the EBUSY teardown-hook
failure in proxy-fallback-ssrf.test.ts (Windows file locking; its 4 assertions pass). Neither is
touched by this change. I did not run the full npm test.

npm run build exits 1 here, but identically on a clean release/v3.8.50 checkout — 2 x
Module not found: Can't resolve 'better-sqlite3', an optionalDependency whose native binding does
not compile on this Windows box. Neither log mentions the changed files. Details in the comment below.

Tests Added Or Updated

  • tests/unit/outbound-guard-mapped-ipv4.test.ts (new, 84 lines)

It fails 10 of 12 on release/v3.8.50 and passes 12/12 with this change, so it pins the
behaviour rather than merely describing it. Covers: mapped metadata literals in both spellings
(IMDS, Alibaba, ECS task role), public hosts staying allowed, parseAndValidateNonMetadataUrl
rejecting mapped metadata while still permitting a private LAN provider endpoint, and ::.

Coverage Notes

Touches one src/ file, src/shared/network/outboundUrlGuard.ts. Every branch added is exercised by
the new test: the dotted-quad path, the hextet path, the malformed-hextet rejection, the
non-::ffff: early return, and the :: literal. Existing coverage of this module
(webhook-ssrf-guard.test.ts, 29 cases) is unchanged and still passes, so coverage on the touched
file moves up, not down.

Reviewer Notes

  • Workflows need a maintainer to approve them. Quality Gates, DAST smoke (PR) and the
    semgrep workflow are all sitting at action_required because this is my first PR from a fork;
    only the Semgrep app check ran on its own (it passed). Nothing is misconfigured — quality.yml
    targets release/** and applies to this PR — the runs just need the approval click. Until then
    the local results above are the only evidence, which is why I listed the exact commands and the
    pre-existing failures rather than a bare "tests pass".
  • The guard remains literal-only, deliberately: a hostname that resolves to a metadata or
    private address (DNS rebinding) is still accepted, and redirects are not re-validated per hop.
    Both are larger architectural changes and out of scope here.
  • No behaviour change for public hosts, and none for private/LAN endpoints in the modes that allow
    them — parseAndValidateNonMetadataUrl("http://192.168.1.50:11434/v1") still resolves (covered by
    a test). The only requests newly refused are IPv4-mapped metadata literals and [::].
  • outboundUrlGuard.ts is the CLI-loadable half of the guard (fix(providers): OpenCode Free models do not appear in Combo Builder on Windows #7682), so the patch adds no
    @/-aliased import — it uses only node:net, which the file already imported.

isCloudMetadataHost() matched cloud-metadata endpoints only in their
dotted-decimal spelling. new URL() serialises an IPv4-mapped IPv6 host as
hextets, so http://[::ffff:169.254.169.254]/ arrives as ::ffff:a9fe:a9fe and
the check never fires for an address the OS routes to 169.254.169.254.

That block is the only control left in the "block-metadata" guard mode, which
is the default for provider validation (areLocalProviderUrlsAllowed defaults
on) and deliberately permits private/LAN targets. The same helper backs
parseAndValidateWebhookUrl, assertSafeCatalogUrl, and the no-auth local
embedding classifier, all of which document metadata as unconditionally
blocked.

Fold the embedded IPv4 out of a mapped literal before matching so the verdict
follows the address. Also block "::" in isPrivateHost(): 0.0.0.0 was already
refused, but its IPv6 twin reaches a service bound to the IPv6 loopback.

The guard stays literal-only — hostnames that resolve to a metadata or private
address, and redirects to one, are unchanged and out of scope here.
@ntdat812
ntdat812 requested a review from diegosouzapw as a code owner August 20, 2026 12:15
@ntdat812

Copy link
Copy Markdown
Contributor Author

Build result, as promised in the description — it fails locally, but identically on a clean base, so it is my environment, not this change.

Tree npm run build Errors
fix/outbound-guard-mapped-ipv4 exit 1 2 × Module not found: Can't resolve 'better-sqlite3'
release/v3.8.50 @ bc6129bc, clean checkout exit 1 2 × Module not found: Can't resolve 'better-sqlite3'

Same count, same message, same resolution chain (src/lib/db/adapters/runtimeRequire.ts → driverFactory → core), reached from Instrumentation / Middleware / Server Component entries. better-sqlite3 is an optionalDependency whose native binding does not compile on this Windows box, so Turbopack cannot resolve it.

Neither log mentions outboundUrlGuard.ts or the new test file at all (0 matches). The changed module imports exactly one thing, node:net, which it already imported before this PR — it cannot participate in a native-module resolution failure.

So I cannot give you a green build from here, and I would rather say that plainly than tick the box. The verification that is meaningful:

  • new test fails 10/12 on release/v3.8.50, passes 12/12 with the patch
  • 73/73 across the touched guard's existing suites (webhook-ssrf-guard, firecrawl-search-ssrf-guard, kiro-region-ssrf-guard, provider-validation-ssrf-guard, api/webhooks/webhook-url-ssrf-guard)
  • ESLint clean on both changed files; check:changelog-integrity OK

Quality Gates / DAST smoke / the semgrep workflow are still at action_required pending approval, so a real CI build is one click away and will be far more authoritative than mine.

@diegosouzapw
diegosouzapw merged commit e5b7c40 into diegosouzapw:release/v3.8.50 Aug 20, 2026
3 checks passed
diegosouzapw pushed a commit that referenced this pull request Aug 24, 2026
Validated on a 17-PR combined board: upstream-proxy-host-spelling 8/8 within the board's 287/287, typecheck:core clean. Routes src/lib/db/upstreamProxy.ts through the shared outbound-guard helpers instead of a private dotted-quad regex copy that had drifted since #10843 — closes the IPv4-mapped IPv6, ULA, link-local and CGNAT bypasses while preserving the deliberate loopback allow (CLIProxyAPI on localhost:8317). Multicast widened from /224\. to the full 224.0.0.0/4, called out explicitly. Thank you @ntdat812!
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…osouzapw#10843)

Obrigado — fix de segurança real (reportado via GHSA-qcfj-c39q-88jh): isCloudMetadataHost() decidia por spelling dotted-decimal, então um literal IPv4-mapped IPv6 (ex.: [::ffff:169.254.169.254]) alcançava o guard já canonicalizado por new URL() e não era reconhecido como endpoint de metadata de cloud — bypass no modo que permite endpoints privados/LAN (o default local-first). Também fecha o gap equivalente de 0.0.0.0/::.

Validação (worktree combinado a partir de origin/release/v3.8.50, 0 conflitos):
- typecheck:core limpo, complexity/cognitive-complexity dentro do baseline
- tests/unit/outbound-guard-mapped-ipv4.test.ts — 12/12 passando (IMDS, Alibaba, ECS task role, ambas as grafias, hosts públicos, guard `::`)
- Suítes SSRF relacionadas (webhook/firecrawl/kiro/provider-validation) — verdes
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…ouzapw#11319)

Validated on a 17-PR combined board: upstream-proxy-host-spelling 8/8 within the board's 287/287, typecheck:core clean. Routes src/lib/db/upstreamProxy.ts through the shared outbound-guard helpers instead of a private dotted-quad regex copy that had drifted since diegosouzapw#10843 — closes the IPv4-mapped IPv6, ULA, link-local and CGNAT bypasses while preserving the deliberate loopback allow (CLIProxyAPI on localhost:8317). Multicast widened from /224\. to the full 224.0.0.0/4, called out explicitly. Thank you @ntdat812!
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