Skip to content

fix(ai): reject redirects in localOnlyFetch and enforce loopback-only base URLs (sec)#537

Open
sroussey wants to merge 2 commits into
mainfrom
claude/beautiful-mayer-HTYiA
Open

fix(ai): reject redirects in localOnlyFetch and enforce loopback-only base URLs (sec)#537
sroussey wants to merge 2 commits into
mainfrom
claude/beautiful-mayer-HTYiA

Conversation

@sroussey
Copy link
Copy Markdown
Collaborator

Security finding (SSRF)

The on-host AI provider clients (llamacpp-server, stable-diffusion-server) only ever talk to a backend on the same host. Two gaps widened that surface into an SSRF vector:

  1. Redirect following in localOnlyFetch. The helper issued requests with redirect: "manual" and followed up to 5 redirects, re-validating each Location against the loopback policy. A compromised/malicious local backend could still abuse the redirect-follow machinery, and redirect: "manual" carries the opaque-response trap (semantics differ across Node/undici, Bun, and the browser).
  2. Base URL validation was local (not loopback). Provider base-URL normalizers used normalizeLocalHttpUrl, which accepts RFC 1918 and link-local (incl. the 169.254.169.254 cloud-metadata IP). An RFC1918 base like http://10.0.0.5:9000 was accepted at config time and only failed later (or not at all) at request time.

Decisions applied (per security review)

  • Use redirect: "error". On-host AI backends never legitimately issue redirects, so any redirect is rejected outright. This closes the redirect SSRF vector uniformly across Node/undici, Bun, and the browser and removes the opaque-response trap. Initial-URL validation (protocol, no credentials, loopback host) still runs before any network call, preserving the "no fetch on a bad URL" guarantee.
  • Enforce loopback-only consistently. Code comments confirm loopback-only is the intended policy, so provider base-URL normalization now rejects RFC 1918 / link-local / ULA at config time.

Changes per file

  • packages/ai/src/provider-utils/localOnlyFetch.ts — Removed the redirect hop loop and helpers (REDIRECT_STATUS_CODES, isRedirectResponse, MAX_REDIRECTS). Body is now a single fetch(url, { ...init, redirect: "error" }) after the existing loopback/credential/scheme validation. A redirect rejection is rethrown as "<label>: refusing redirect from local backend"; abort errors and other network errors (and the AbortSignal) pass through unchanged. Signature and streaming passthrough preserved. Doc comment updated to state redirects are rejected outright.
  • packages/ai/src/provider-utils/localUrl.ts — Added and exported normalizeLoopbackHttpUrl(rawUrl, label), a loopback-only twin of normalizeLocalHttpUrl (validates the raw host with isLoopbackHostname). Both now share a private normalizeHttpUrl implementation (same parsing/credential/canonicalisation logic; only the host predicate and policy word differ). No existing exports weakened.
  • providers/llamacpp-server/src/ai/common/LlamaCppServer_Client.ts and providers/stable-diffusion-server/src/ai/common/StableDiffusionCpp_Client.ts — Repointed normalizeServerBaseUrl (used by acquireBaseUrl) from normalizeLocalHttpUrl to normalizeLoopbackHttpUrl, so a non-loopback base fails at config time with a clear message.
  • packages/test/src/test/ai-provider-api/localOnlyFetch.test.ts — Replaced the redirect-FOLLOW stub tests (follow-to-loopback, relative Location, 5-hop cap, 300/304-not-followed) with tests that stub fetch to reject (simulating redirect:"error") and assert the labeled redirect error and that redirect: "error" was used; added an AbortError-passthrough test. Kept all initial-URL validation tests (non-loopback, credentials, non-HTTP(S), RFC1918, external).
  • packages/test/src/test/ai-provider-api/localOnlyFetch.integration.test.ts (new) — Real http.createServer on 127.0.0.1:0 with a /ok (200, body asserted) and /redirect (302, asserts localOnlyFetch rejects) endpoint, plus config-validation tests asserting normalizeLoopbackHttpUrl("http://10.0.0.5:9000","X") throws and a loopback base is accepted.

Validation

NOT run in this environment — there is no checkout and no build/test runner available here. The implementation was made carefully against the source at main. CI must validate type-check, lint, and the unit + integration tests. Two points worth reviewer attention: (a) the redirect-rejection detection is a best-effort message match (/redirect/i) since the concrete error type differs across runtimes; if a runtime's redirect:"error" rejection lacks that fragment, the integration test still passes (it only asserts a throw) but the unit test's label assertion depends on it; (b) the integration test uses node:http, so it runs under the Node test target.


Generated by Claude Code

sroussey added 2 commits May 27, 2026 01:14
… base URLs (sec)

Close a redirect-based SSRF vector in the on-host AI provider clients.

localOnlyFetch previously followed up to 5 redirects, re-validating each
Location against the loopback policy. A malicious/compromised local backend
could still abuse the redirect-follow machinery and the redirect:"manual"
opaque-response trap. On-host AI backends never legitimately redirect, so we
now issue the request with redirect:"error" and reject any redirect outright,
uniformly across Node/undici, Bun, and the browser. Initial-URL validation
(protocol, credentials, loopback host) still runs before any network call.

Add normalizeLoopbackHttpUrl, a loopback-only twin of normalizeLocalHttpUrl,
and repoint the llamacpp-server and stable-diffusion-server base-URL
normalizers at it so an RFC1918 base (e.g. http://10.0.0.5:9000) fails at
config time with a clear message instead of silently at request time.

Update tests: redirect-FOLLOW stubs replaced with redirect:"error" rejection
assertions; initial-URL validation tests kept; add a real loopback http
integration test and a config-validation test.
…ding

The llamacpp-server and stable-diffusion-server clients now normalize their
base URL via normalizeLoopbackHttpUrl (loopback-only policy), whose rejection
message reads "...must target a loopback HTTP(S) server" rather than the old
"local HTTP(S)" wording from normalizeLocalHttpUrl. The "rejects public model
URLs" acquireBaseUrl tests still asserted /local HTTP/, which no longer matches
the loopback message and failed the test-vitest-unit job (these unit-kind files
run there, not in the integration-only provider-api job). Update both regexes
to /loopback HTTP/ to match the intentional new loopback-only contract; the
public host example.com is still correctly rejected.
@github-actions
Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 62.09% 24594 / 39608
🔵 Statements 61.95% 25447 / 41076
🔵 Functions 63.05% 4663 / 7395
🔵 Branches 50.76% 12061 / 23759
File CoverageNo changed files found.
Generated in workflow #2443 for commit 129be65 by the Vitest Coverage Report Action

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