fix: close redirect-based SSRF bypass in local provider fetches (sec)#536
Conversation
…s against local-only allow-list (sec) PR #533 added a local-only base-URL allow-list (isLocalHostname / normalizeLocalHttpUrl / extractRawHost in @workglow/ai provider-utils). It validates ONLY the initial base URL. Provider clients then call bare fetch(...) with the default redirect:"follow", so a validated local server that returns `302 Location: http://169.254.169.254/...` is followed transparently to the new host — a residual SSRF bypass (e.g. cloud-metadata exfiltration) that base-URL-only validation cannot stop. Add localOnlyFetch (packages/ai/src/provider-utils/localOnlyFetch.ts): fetch() with redirect:"manual" that re-validates EVERY 3xx Location (resolved against the current URL) through the same isLocalHostname allow-list, rejects non-HTTP(S) and credentialed redirect targets, and caps the chain at 5 hops. Re-export it from the provider-utils barrel. Route every provider fetch() that talks to an on-host backend through localOnlyFetch with a provider label (LlamaCppServer / StableDiffusionCpp): llamacpp-server ModelInfo (x2), ModelSearch, TextGeneration, ToolCalling, TextEmbedding, TextSummary, TextRewriter; stable-diffusion-server ModelInfo, ModelSearch, ImageGenerate, ImageEdit. Streaming response handling is unchanged — the helper returns the final Response. Adds packages/test/src/test/ai-provider-api/localOnlyFetch.test.ts.
There was a problem hiding this comment.
Pull request overview
This PR hardens “local backend” provider HTTP calls against redirect-based SSRF by introducing a localOnlyFetch helper that manually follows redirects and re-validates redirect targets, then updating llama.cpp-server and stable-diffusion-server provider call sites to use it.
Changes:
- Add
packages/ai/src/provider-utils/localOnlyFetch.tsand re-export it from@workglow/ai/provider-utils. - Replace direct
fetch(...)calls in local-backend providers withlocalOnlyFetch(...). - Add Vitest coverage for redirect handling in
localOnlyFetch.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| providers/stable-diffusion-server/src/ai/common/StableDiffusionCpp_ModelSearch.ts | Switch model list fetch to localOnlyFetch |
| providers/stable-diffusion-server/src/ai/common/StableDiffusionCpp_ModelInfo.ts | Switch /v1/models fetch to localOnlyFetch |
| providers/stable-diffusion-server/src/ai/common/StableDiffusionCpp_ImageGenerate.ts | Switch image generation POST to localOnlyFetch |
| providers/stable-diffusion-server/src/ai/common/StableDiffusionCpp_ImageEdit.ts | Switch img2img POST to localOnlyFetch |
| providers/llamacpp-server/src/ai/common/LlamaCppServer_ToolCalling.ts | Switch chat completions POST to localOnlyFetch |
| providers/llamacpp-server/src/ai/common/LlamaCppServer_TextSummary.ts | Switch chat completions POST to localOnlyFetch |
| providers/llamacpp-server/src/ai/common/LlamaCppServer_TextRewriter.ts | Switch chat completions POST to localOnlyFetch |
| providers/llamacpp-server/src/ai/common/LlamaCppServer_TextGeneration.ts | Switch chat completions POST to localOnlyFetch |
| providers/llamacpp-server/src/ai/common/LlamaCppServer_TextEmbedding.ts | Switch embeddings POST to localOnlyFetch |
| providers/llamacpp-server/src/ai/common/LlamaCppServer_ModelSearch.ts | Switch model list fetch to localOnlyFetch |
| providers/llamacpp-server/src/ai/common/LlamaCppServer_ModelInfo.ts | Switch /props and /v1/models fetches to localOnlyFetch |
| packages/test/src/test/ai-provider-api/localOnlyFetch.test.ts | Add redirect-chain SSRF regression tests for localOnlyFetch |
| packages/ai/src/provider-utils/localOnlyFetch.ts | New redirect-validating fetch helper for “local-only” provider traffic |
| packages/ai/src/provider-utils.ts | Re-export localOnlyFetch from provider-utils barrel |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let current = input; | ||
| for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { | ||
| const res = await fetch(current, { ...init, redirect: "manual" }); | ||
| if (res.status < 300 || res.status >= 400) return res; |
There was a problem hiding this comment.
Fixed in 80b3c33. localOnlyFetch now parses and validates the initial input URL (protocol, credentials, loopback host) before any network call, so a bad initial URL throws with zero fetches issued.
Generated by Claude Code
| const host = next.hostname.replace(/^\[|\]$/g, ""); | ||
| if (!isLocalHostname(host)) { | ||
| throw new Error(`${label}: refusing redirect to non-local host (${next.href}).`); | ||
| } |
There was a problem hiding this comment.
Fixed in 80b3c33. Redirect validation now requires the new isLoopbackHostname (loopback-only: localhost, 127.0.0.0/8, ::1, IPv4-mapped loopback) instead of isLocalHostname, so 169.254.169.254 and the rest of 169.254.0.0/16 link-local are rejected. These servers are localhost-only by design.
Generated by Claude Code
| const res = await fetch(current, { ...init, redirect: "manual" }); | ||
| if (res.status < 300 || res.status >= 400) return res; | ||
| const location = res.headers.get("location"); | ||
| if (!location) return res; |
There was a problem hiding this comment.
Fixed in 80b3c33. Redirect following is now restricted to the standard redirect codes 301/302/303/307/308 (a REDIRECT_STATUS_CODES Set). Other 3xx responses (e.g. 300, 304, 306) are returned unchanged even if they carry a Location. The numeric-status + headers.get guard is retained so non-spec test doubles still pass through as terminal.
Generated by Claude Code
| it("refuses a redirect to a non-local host (cloud metadata) after one fetch", async () => { | ||
| stubFetch([redirect("http://169.254.169.254/latest/meta-data/")]); | ||
| await expect( | ||
| localOnlyFetch("http://127.0.0.1:9000/v1/models", undefined, "TestProvider") | ||
| ).rejects.toThrow(/non-local host/); | ||
| expect(calls).toHaveLength(1); |
There was a problem hiding this comment.
Fixed in 80b3c33. The contradiction is gone: 169.254.169.254 is now a REJECTED redirect target (asserts throws with exactly one fetch), and an RFC 1918 case (10.0.0.5) was added to prove the policy is loopback-only, not merely "non-public". The comments no longer describe link-local as in-scope.
Generated by Claude Code
| redirect("http://127.0.0.1:9000/d"), | ||
| redirect("http://127.0.0.1:9000/e"), | ||
| redirect("http://127.0.0.1:9000/f"), | ||
| redirect("http://127.0.0.1:9000/g"), |
There was a problem hiding this comment.
Fixed in 80b3c33. The queue and comment now agree: 6 redirect responses are queued (hops 0..5, six fetches), which exhausts MAX_REDIRECTS=5 and throws on the count guard; the test also asserts exactly 6 fetches. The stray 7th response was removed.
Generated by Claude Code
Only treat a response as a 3xx redirect when its status is a real number
in [300,400) and it exposes a headers.get() method. Minimal test doubles
(e.g. { ok: true, json }) have undefined status/headers; previously
`undefined < 300 || undefined >= 400` was false, so they were misclassified
as redirects and `res.headers.get(...)` threw a TypeError, breaking
LocalBackendsProviderContracts unit tests. Real redirect Responses always
carry a numeric status and headers, so the SSRF re-validation on every hop
is unchanged.
The "refuses a redirect to a non-local host" case targeted 169.254.169.254, but that address lives in the 169.254.0.0/16 link-local block, which isLocalHostname intentionally treats as local (link-local backends are in scope). localOnlyFetch therefore followed the redirect instead of rejecting it, ran the stub queue dry, and threw "no response queued" rather than the expected "non-local host" message, failing the assertion. Point the case at a genuinely external address (RFC 5737 TEST-NET 203.0.113.10) so it exercises the non-local rejection path without altering the SSRF allow-list.
Coverage Report
File CoverageNo changed files found. |
The bug (HIGH severity SSRF residual)
PR #533 added a strict local-only allow-list (
isLocalHostname/normalizeLocalHttpUrl/extractRawHostin@workglow/aiprovider-utils,
packages/ai/src/provider-utils/localUrl.ts) so thatllama.cpp-server and stable-diffusion.cpp-server providers only ever talk
to on-host backends.
That allow-list only validates the initial base URL. The provider
clients then issue bare
fetch(...)calls, which use the defaultredirect: "follow". A validated local server (or anything that can sitat the configured base URL) that responds with, e.g.:
is followed transparently to the new host. The allow-list never sees
the redirect target, so the local-only guarantee is bypassed — classic
SSRF (cloud-metadata exfiltration, internal-service probing, etc.).
The fix
New helper
packages/ai/src/provider-utils/localOnlyFetch.ts:redirect: "manual"and walks the redirect chain itself.Location(resolved against the current URLso relative redirects work) through the same
isLocalHostnameallow-list used for the base URL.
MAX_REDIRECTS) and throws on overflow.Responseunchanged, so streaming consumers(
readChatCompletionDeltas,res.json(), etc.) are unaffected.Re-exported from the
provider-utilsbarrel.isLocalHostnamewasalready exported by
localUrl.ts, so no change was needed there.Changed call sites
Every provider
fetch()that talks to an on-host backend now goesthrough
localOnlyFetch(url, init, "<ProviderLabel>"):llamacpp-server (
providers/llamacpp-server/src/ai/common/)LlamaCppServer_ModelInfo.ts(both/propsand/v1/modelscalls)LlamaCppServer_ModelSearch.tsLlamaCppServer_TextGeneration.tsLlamaCppServer_ToolCalling.tsLlamaCppServer_TextEmbedding.tsLlamaCppServer_TextSummary.tsLlamaCppServer_TextRewriter.tsstable-diffusion-server (
providers/stable-diffusion-server/src/ai/common/)StableDiffusionCpp_ModelInfo.tsStableDiffusionCpp_ModelSearch.tsStableDiffusionCpp_ImageGenerate.tsStableDiffusionCpp_ImageEdit.tsOnly the
fetch->localOnlyFetchsubstitution and the necessaryimport were changed; request bodies, headers, signals and streaming
response handling are untouched.
Tests
Adds
packages/test/src/test/ai-provider-api/localOnlyFetch.test.ts(vitest, matching the sibling
localUrl.test.ts). It stubs globalfetchwith a queued list ofResponseobjects and asserts:http://169.254.169.254/...throws (non-local), one fetch issued.https://evil.example.comthrows.http://127.0.0.1:9000/v1/modelsis followed; final 200body returned.
Location: /v1/modelsagainst a local base is followed.fetchrestored inafterEach.Test plan
@workglow/ai, llamacpp-server, stable-diffusion-server.ai-provider-apitest section (incl.localOnlyFetch.test.ts).LlamaCppServer_Client/StableDiffusionCpp_Clienttests still pass.Generated by Claude Code