fix(ai): reject redirects in localOnlyFetch and enforce loopback-only base URLs (sec)#537
Open
sroussey wants to merge 2 commits into
Open
fix(ai): reject redirects in localOnlyFetch and enforce loopback-only base URLs (sec)#537sroussey wants to merge 2 commits into
sroussey wants to merge 2 commits into
Conversation
… 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.
Coverage Report
File CoverageNo changed files found. |
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.
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:localOnlyFetch. The helper issued requests withredirect: "manual"and followed up to 5 redirects, re-validating eachLocationagainst the loopback policy. A compromised/malicious local backend could still abuse the redirect-follow machinery, andredirect: "manual"carries the opaque-response trap (semantics differ across Node/undici, Bun, and the browser).local(notloopback). Provider base-URL normalizers usednormalizeLocalHttpUrl, which accepts RFC 1918 and link-local (incl. the169.254.169.254cloud-metadata IP). An RFC1918 base likehttp://10.0.0.5:9000was accepted at config time and only failed later (or not at all) at request time.Decisions applied (per security review)
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.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 singlefetch(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 theAbortSignal) 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 exportednormalizeLoopbackHttpUrl(rawUrl, label), a loopback-only twin ofnormalizeLocalHttpUrl(validates the raw host withisLoopbackHostname). Both now share a privatenormalizeHttpUrlimplementation (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.tsandproviders/stable-diffusion-server/src/ai/common/StableDiffusionCpp_Client.ts— RepointednormalizeServerBaseUrl(used byacquireBaseUrl) fromnormalizeLocalHttpUrltonormalizeLoopbackHttpUrl, 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, relativeLocation, 5-hop cap, 300/304-not-followed) with tests that stubfetchto reject (simulatingredirect:"error") and assert the labeled redirect error and thatredirect: "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) — Realhttp.createServeron127.0.0.1:0with a/ok(200, body asserted) and/redirect(302, assertslocalOnlyFetchrejects) endpoint, plus config-validation tests assertingnormalizeLoopbackHttpUrl("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'sredirect:"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 usesnode:http, so it runs under the Node test target.Generated by Claude Code