diff --git a/packages/ai/src/provider-utils/localOnlyFetch.ts b/packages/ai/src/provider-utils/localOnlyFetch.ts index 504abd3d2..a242a45cb 100644 --- a/packages/ai/src/provider-utils/localOnlyFetch.ts +++ b/packages/ai/src/provider-utils/localOnlyFetch.ts @@ -6,66 +6,43 @@ import { isLoopbackHostname } from "./localUrl"; -const MAX_REDIRECTS = 5; - -/** - * Standard HTTP redirect status codes per the Fetch/HTTP specs. A 3xx that is - * NOT one of these (e.g. `300 Multiple Choices`, `304 Not Modified`, - * `306 (unused)`) is NOT a redirect even if it carries a `Location` header — - * such responses are returned to the caller unchanged. - */ -const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]); - -/** - * Returns true only when `res` is a spec-shaped standard redirect: a numeric - * `status` in {301,302,303,307,308} AND a `headers.get` method we can read - * `location` from. Real `fetch` responses satisfy both; minimal test doubles - * (e.g. `{ ok: true, json }`) have `undefined` status/headers and are - * therefore treated as terminal responses rather than misclassified as - * redirects (which would throw on `res.headers.get`). This narrows the - * redirect path without weakening validation of genuine redirects. - */ -function isRedirectResponse(res: Response): boolean { - const status = res.status; - if (typeof status !== "number" || !REDIRECT_STATUS_CODES.has(status)) return false; - return typeof res.headers?.get === "function"; -} - /** * Validate a request target against the strict LOOPBACK-ONLY policy: it must * be a valid http(s) URL, carry no credentials, and resolve to a loopback * host (`localhost`, `127.0.0.0/8`, `::1`, or IPv4-mapped loopback). Throws a - * generic, label-prefixed Error otherwise. `context` distinguishes the - * initial URL from a redirect in the message. + * generic, label-prefixed Error otherwise. */ -function assertLoopbackTarget(url: URL, label: string, context: "initial URL" | "redirect"): void { +function assertLoopbackTarget(url: URL, label: string): void { if (url.protocol !== "http:" && url.protocol !== "https:") { - throw new Error(`${label}: refusing ${context} to non-HTTP(S) URL.`); + throw new Error(`${label}: refusing initial URL to non-HTTP(S) URL.`); } if (url.username || url.password) { - throw new Error(`${label}: refusing ${context} with credentials.`); + throw new Error(`${label}: refusing initial URL with credentials.`); } const host = url.hostname.replace(/^\[|\]$/g, ""); if (!isLoopbackHostname(host)) { - throw new Error(`${label}: refusing ${context} to non-loopback host (${url.href}).`); + throw new Error(`${label}: refusing initial URL to non-loopback host (${url.href}).`); } } /** - * fetch() restricted to STRICTLY-LOOPBACK hosts on EVERY hop. These AI server - * clients only ever talk to a backend on the same host, so a broad "local" - * allow-list (which includes RFC 1918 and the `169.254.169.254` cloud-metadata - * address) is wider than needed and is itself an SSRF vector. This wrapper - * therefore enforces loopback-only on: - * - the initial `input` URL, validated defensively BEFORE any network call - * (a bad initial URL throws with zero fetches issued); and - * - every redirect `Location`, resolved against the current URL and - * re-validated, closing the redirect-based SSRF bypass left by - * base-URL-only validation. + * fetch() restricted to STRICTLY-LOOPBACK hosts. These AI server clients only + * ever talk to a backend on the same host, so a broad "local" allow-list + * (which includes RFC 1918 and the `169.254.169.254` cloud-metadata address) + * is wider than needed and is itself an SSRF vector. This wrapper therefore: + * - validates the initial `input` URL defensively BEFORE any network call — + * a bad initial URL throws with zero fetches issued; and + * - issues the request with `redirect: "error"`, so ANY redirect from the + * local backend is REJECTED OUTRIGHT rather than followed. On-host AI + * backends never legitimately issue redirects, so following them only + * opens a redirect-based SSRF bypass (and the opaque-response trap of + * `redirect: "manual"`). Rejecting redirects outright closes the vector + * uniformly across Node/undici, Bun, and the browser. * - * Only standard redirect codes (301/302/303/307/308) are followed; other 3xx - * responses are returned unchanged. The final Response is returned untouched - * so streaming consumers are unaffected. + * A redirect rejection is rethrown as a clear, label-prefixed error. Abort + * and network errors (including the AbortSignal in `init`) pass through + * unchanged. The final Response is returned untouched so streaming consumers + * are unaffected. */ export async function localOnlyFetch( input: string, @@ -80,17 +57,42 @@ export async function localOnlyFetch( } catch { throw new Error(`${label}: invalid initial URL.`); } - assertLoopbackTarget(initialUrl, label, "initial URL"); + assertLoopbackTarget(initialUrl, label); - let current = initialUrl.href; - for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { - const res = await fetch(current, { ...init, redirect: "manual" }); - if (!isRedirectResponse(res)) return res; - const location = res.headers.get("location"); - if (!location) return res; - const next = new URL(location, current); - assertLoopbackTarget(next, label, "redirect"); - current = next.href; + try { + return await fetch(initialUrl.href, { ...init, redirect: "error" }); + } catch (err) { + // Re-raise abort/network errors unchanged so callers (and the + // AbortSignal) keep their existing semantics. A redirect rejection from + // `redirect: "error"` is surfaced as a clear, labeled error. + if (isAbortError(err)) throw err; + if (isRedirectError(err)) { + throw new Error(`${label}: refusing redirect from local backend`, { cause: err }); + } + throw err; } - throw new Error(`${label}: too many redirects (> ${MAX_REDIRECTS}).`); +} + +/** + * True if `err` is an AbortError (from an aborted AbortSignal). Such errors + * must pass through `localOnlyFetch` unchanged. + */ +function isAbortError(err: unknown): boolean { + return ( + err instanceof Error && + (err.name === "AbortError" || (err as { code?: string }).code === "ABORT_ERR") + ); +} + +/** + * Best-effort detection of the rejection produced by `redirect: "error"` when + * the server responds with a redirect. The exact error class/message differs + * across runtimes (Node/undici, Bun, browsers), so we match on the well-known + * message fragment rather than a single concrete type. + */ +function isRedirectError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const cause = (err as { cause?: { message?: string } }).cause; + const msg = `${err.message} ${cause?.message ?? ""}`.toLowerCase(); + return msg.includes("redirect"); } diff --git a/packages/ai/src/provider-utils/localUrl.ts b/packages/ai/src/provider-utils/localUrl.ts index eb74b08c5..f57e962a6 100644 --- a/packages/ai/src/provider-utils/localUrl.ts +++ b/packages/ai/src/provider-utils/localUrl.ts @@ -271,9 +271,9 @@ function isLoopbackIpv6(host: string): boolean { * Everything else — RFC 1918, link-local (incl. the `169.254.169.254` * cloud-metadata IP), ULA, public, `*.localhost`, IDN, percent-encoded, * and unsigned-integer IPv4 spellings — returns false. This is intentionally - * narrower than {@link isLocalHostname}: it is the initial-URL and - * redirect-hop gate used by `localOnlyFetch` to close the link-local - * metadata SSRF vector. + * narrower than {@link isLocalHostname}: it is the initial-URL gate used by + * `localOnlyFetch` and (via {@link normalizeLoopbackHttpUrl}) the base-URL + * gate that closes the link-local metadata SSRF vector. * * IPv6 literals must be passed WITHOUT surrounding brackets. */ @@ -331,15 +331,49 @@ export function extractRawHost(rawUrl: string): string | null { * spellings that WHATWG would canonicalise to a local literal). */ export function normalizeLocalHttpUrl(rawUrl: string, label: string): string { + return normalizeHttpUrl(rawUrl, label, isLocalHostname, "local"); +} + +/** + * Loopback-only twin of {@link normalizeLocalHttpUrl}: identical parsing, + * credential, scheme and canonicalisation logic, but the raw host literal is + * validated with {@link isLoopbackHostname} instead of {@link isLocalHostname}. + * + * This rejects RFC 1918, link-local (incl. the `169.254.169.254` + * cloud-metadata IP), and ULA bases at CONFIG time with a clear message, + * rather than letting them silently fail later at request time. Provider + * base-URL normalizers use this so a base like `http://10.0.0.5:9000` is + * refused up front. + * + * @throws Error if the URL is malformed, not http(s), carries credentials, + * or targets a non-loopback hostname (including non-literal IPv4 + * spellings that WHATWG would canonicalise to a loopback literal). + */ +export function normalizeLoopbackHttpUrl(rawUrl: string, label: string): string { + return normalizeHttpUrl(rawUrl, label, isLoopbackHostname, "loopback"); +} + +/** + * Shared implementation behind {@link normalizeLocalHttpUrl} and + * {@link normalizeLoopbackHttpUrl}. The only difference between the two is the + * host predicate (`isLocalHostname` vs `isLoopbackHostname`) and the word used + * in the thrown message. + */ +function normalizeHttpUrl( + rawUrl: string, + label: string, + isAllowedHost: (host: string) => boolean, + policyWord: "local" | "loopback", +): string { let url: URL; try { url = new URL(rawUrl); } catch { - throw new Error(`${label}: base URL must be a valid local HTTP(S) URL.`); + throw new Error(`${label}: base URL must be a valid ${policyWord} HTTP(S) URL.`); } if (url.protocol !== "http:" && url.protocol !== "https:") { - throw new Error(`${label}: base URL must be a valid local HTTP(S) URL.`); + throw new Error(`${label}: base URL must be a valid ${policyWord} HTTP(S) URL.`); } if (url.username || url.password) { throw new Error(`${label}: base URL must not include credentials.`); @@ -348,10 +382,12 @@ export function normalizeLocalHttpUrl(rawUrl: string, label: string): string { // Validate the LITERAL host from rawUrl, not `url.hostname` — the // WHATWG parser rewrites non-standard IPv4 spellings (hex, decimal, // leading-zero octets) into canonical dotted-quads that would slip - // past `isLocalHostname`. + // past the strict-literal host predicate. const rawHost = extractRawHost(rawUrl); - if (rawHost === null || !isLocalHostname(rawHost)) { - throw new Error(`${label}: base URL must target a local HTTP(S) server (got: ${rawUrl}).`); + if (rawHost === null || !isAllowedHost(rawHost)) { + throw new Error( + `${label}: base URL must target a ${policyWord} HTTP(S) server (got: ${rawUrl}).` + ); } // Strip trailing slashes from the path (but keep a single "/" — handled by diff --git a/packages/test/src/test/ai-provider-api/LlamaCppServer_Client.test.ts b/packages/test/src/test/ai-provider-api/LlamaCppServer_Client.test.ts index 721819f62..72ed83294 100644 --- a/packages/test/src/test/ai-provider-api/LlamaCppServer_Client.test.ts +++ b/packages/test/src/test/ai-provider-api/LlamaCppServer_Client.test.ts @@ -91,7 +91,7 @@ describe("acquireBaseUrl precedence", () => { it("rejects public model URLs before requests can use them", async () => { await expect( acquireBaseUrl({ provider_config: { base_url: "https://example.com:8080/" } } as any, {}) - ).rejects.toThrow(/local HTTP/); + ).rejects.toThrow(/loopback HTTP/); }); it("normalizes slash-heavy local URLs", async () => { diff --git a/packages/test/src/test/ai-provider-api/StableDiffusionCpp_Client.test.ts b/packages/test/src/test/ai-provider-api/StableDiffusionCpp_Client.test.ts index 68573fa1b..36d91094b 100644 --- a/packages/test/src/test/ai-provider-api/StableDiffusionCpp_Client.test.ts +++ b/packages/test/src/test/ai-provider-api/StableDiffusionCpp_Client.test.ts @@ -77,7 +77,7 @@ describe("acquireBaseUrl precedence", () => { it("rejects public model URLs before requests can use them", async () => { await expect( acquireBaseUrl({ provider_config: { base_url: "https://example.com:8080/" } } as any, {}) - ).rejects.toThrow(/local HTTP/); + ).rejects.toThrow(/loopback HTTP/); }); it("normalizes slash-heavy local URLs", async () => { diff --git a/packages/test/src/test/ai-provider-api/localOnlyFetch.integration.test.ts b/packages/test/src/test/ai-provider-api/localOnlyFetch.integration.test.ts new file mode 100644 index 000000000..2faf5ef05 --- /dev/null +++ b/packages/test/src/test/ai-provider-api/localOnlyFetch.integration.test.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Integration tests for `localOnlyFetch` against a REAL loopback HTTP server. + * + * Unlike the unit tests (which stub `fetch`), these spin up an + * `http.createServer` bound to `127.0.0.1:0` so we exercise the actual + * `redirect: "error"` behaviour of the host runtime's `fetch`: + * - a 200 endpoint returns its body; and + * - a 302 endpoint causes `localOnlyFetch` to REJECT (the redirect is never + * followed). + * + * Also asserts the config-time gate: `normalizeLoopbackHttpUrl` throws on an + * RFC 1918 base URL. + */ + +import { localOnlyFetch, normalizeLoopbackHttpUrl } from "@workglow/ai/provider-utils"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +let server: Server; +let base: string; + +beforeAll(async () => { + server = createServer((req, res) => { + if (req.url === "/ok") { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("integration-ok"); + return; + } + if (req.url === "/redirect") { + res.writeHead(302, { location: "/ok" }); + res.end(); + return; + } + res.writeHead(404); + res.end(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const addr = server.address() as AddressInfo; + base = `http://127.0.0.1:${addr.port}`; +}); + +afterAll(async () => { + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ); +}); + +describe("localOnlyFetch (integration, real loopback server)", () => { + it("returns the body from a 200 loopback endpoint", async () => { + const res = await localOnlyFetch(`${base}/ok`, undefined, "TestProvider"); + expect(res.status).toBe(200); + expect(await res.text()).toBe("integration-ok"); + }); + + it("rejects rather than following a 302 from the loopback backend", async () => { + await expect( + localOnlyFetch(`${base}/redirect`, undefined, "TestProvider") + ).rejects.toThrow(); + }); +}); + +describe("normalizeLoopbackHttpUrl (config-time gate)", () => { + it("throws on an RFC 1918 base URL", () => { + expect(() => normalizeLoopbackHttpUrl("http://10.0.0.5:9000", "X")).toThrow(); + }); + + it("accepts a loopback base URL", () => { + expect(normalizeLoopbackHttpUrl("http://127.0.0.1:9000/v1", "X")).toBe( + "http://127.0.0.1:9000/v1" + ); + }); +}); diff --git a/packages/test/src/test/ai-provider-api/localOnlyFetch.test.ts b/packages/test/src/test/ai-provider-api/localOnlyFetch.test.ts index 4d1c1a7f4..10e466352 100644 --- a/packages/test/src/test/ai-provider-api/localOnlyFetch.test.ts +++ b/packages/test/src/test/ai-provider-api/localOnlyFetch.test.ts @@ -12,13 +12,13 @@ * directories under `packages/test/src/test`). The helper itself lives in * `packages/ai/src/provider-utils/localOnlyFetch.ts`. * - * These tests stub the global `fetch` with a queue of `Response` objects and - * assert the STRICT LOOPBACK-ONLY policy: the initial URL is validated before - * any network call, and each standard 3xx `Location` is re-validated and must - * be loopback before being followed. The local AI servers run on localhost - * ONLY by design, so RFC 1918 and link-local (incl. the 169.254.169.254 - * cloud-metadata IP) are rejected — closing the redirect-based SSRF bypass - * that base-URL-only validation left open. + * These tests stub the global `fetch` and assert the STRICT LOOPBACK-ONLY + * policy: the initial URL is validated before any network call, and the + * request is issued with `redirect: "error"` so ANY redirect from the local + * backend is rejected outright (never followed). On-host AI backends never + * legitimately redirect, so this closes the redirect-based SSRF bypass that + * base-URL-only validation left open — uniformly across Node/undici, Bun, + * and the browser. */ import { localOnlyFetch } from "@workglow/ai/provider-utils"; @@ -36,7 +36,8 @@ let calls: RecordedCall[]; /** * Install a stub `fetch` that returns the queued responses in order. Each - * call records the requested URL so tests can assert the exact hop count. + * call records the requested URL and redirect mode so tests can assert the + * exact call shape. */ function stubFetch(responses: Response[]): void { let i = 0; @@ -54,20 +55,20 @@ function stubFetch(responses: Response[]): void { }) as typeof fetch; } -/** Build a 3xx redirect response pointing at `location`. */ -function redirect(location: string, status = 302): Response { - return new Response(null, { - status, - headers: { location }, - }); -} - -/** Build a response with an explicit status carrying a `Location` header. */ -function statusWithLocation(status: number, location: string): Response { - return new Response(null, { - status, - headers: { location }, - }); +/** + * Install a stub `fetch` that REJECTS with `err` — used to simulate the + * rejection a real runtime throws under `redirect: "error"` when the server + * responds with a 3xx redirect. + */ +function stubFetchReject(err: Error): void { + calls = []; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ + url: String(input), + redirect: init?.redirect, + }); + return Promise.reject(err); + }) as typeof fetch; } /** Build a terminal 200 response carrying `body`. */ @@ -84,112 +85,63 @@ describe("localOnlyFetch", () => { calls = []; }); - it("refuses a redirect to the cloud-metadata link-local IP after one fetch", async () => { - // 169.254.169.254 is the cloud-metadata address. The broad "local" - // allow-list treats 169.254.0.0/16 as in-scope link-local, which is - // exactly the SSRF vector this wrapper closes — under the loopback-only - // policy it must be rejected, not followed. - 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-loopback host/); + it("issues the request with redirect: \"error\" and returns a 200 unchanged", async () => { + stubFetch([ok("plain-body")]); + const res = await localOnlyFetch( + "http://127.0.0.1:9000/v1/models", + undefined, + "TestProvider" + ); + expect(await res.text()).toBe("plain-body"); expect(calls).toHaveLength(1); + expect(calls[0].redirect).toBe("error"); }); - it("refuses a redirect to a non-local public host after one fetch", async () => { - // 203.0.113.10 is RFC 5737 TEST-NET-3 documentation space — unambiguously - // non-local, so the redirect must be rejected, not followed. - stubFetch([redirect("http://203.0.113.10/latest/meta-data/")]); + it("throws a labeled redirect error when fetch rejects (simulating redirect: \"error\")", async () => { + // Under `redirect: "error"` a real runtime rejects the promise when the + // server responds with a 3xx. The exact error differs across runtimes, so + // we match on the well-known "redirect" message fragment. + stubFetchReject(new TypeError("fetch failed: unexpected redirect")); await expect( localOnlyFetch("http://127.0.0.1:9000/v1/models", undefined, "TestProvider") - ).rejects.toThrow(/non-loopback host/); + ).rejects.toThrow(/TestProvider: refusing redirect from local backend/); expect(calls).toHaveLength(1); + expect(calls[0].redirect).toBe("error"); }); - it("refuses a redirect to an RFC 1918 private host after one fetch", async () => { - // 10.0.0.5 is RFC 1918 private space — "local" under the broad allow-list - // but NOT loopback. Proves the policy is loopback-only, not merely - // "non-public". - stubFetch([redirect("http://10.0.0.5/internal")]); + it("passes an AbortError through unchanged", async () => { + const abort = new DOMException("The operation was aborted.", "AbortError"); + stubFetchReject(abort); await expect( localOnlyFetch("http://127.0.0.1:9000/v1/models", undefined, "TestProvider") - ).rejects.toThrow(/non-loopback host/); + ).rejects.toBe(abort); expect(calls).toHaveLength(1); + expect(calls[0].redirect).toBe("error"); }); - it("refuses a redirect to an external host", async () => { - stubFetch([redirect("https://evil.example.com/steal")]); + it("rejects a non-loopback initial URL before issuing any fetch", async () => { + // 169.254.169.254 is the cloud-metadata address — "local" under the broad + // allow-list but NOT loopback. Queue a response that must never be + // consumed: validation happens before the first network call. + stubFetch([ok("should-not-be-reached")]); await expect( - localOnlyFetch("http://127.0.0.1:9000/v1/models", undefined, "TestProvider") + localOnlyFetch("http://169.254.169.254/latest/meta-data/", undefined, "TestProvider") ).rejects.toThrow(/non-loopback host/); - expect(calls).toHaveLength(1); - }); - - it("follows a redirect to another loopback host and returns the final body", async () => { - stubFetch([redirect("http://127.0.0.1:9000/v1/models"), ok("final-body")]); - const res = await localOnlyFetch( - "http://localhost:8080/v1/models", - undefined, - "TestProvider" - ); - expect(await res.text()).toBe("final-body"); - expect(calls).toHaveLength(2); - expect(calls[1].url).toBe("http://127.0.0.1:9000/v1/models"); - }); - - it("follows a relative Location resolved against a loopback base", async () => { - stubFetch([redirect("/v1/models"), ok("relative-body")]); - const res = await localOnlyFetch( - "http://127.0.0.1:9000/props", - undefined, - "TestProvider" - ); - expect(await res.text()).toBe("relative-body"); - expect(calls).toHaveLength(2); - expect(calls[1].url).toBe("http://127.0.0.1:9000/v1/models"); - }); - - it("returns a non-redirect 200 unchanged with exactly one fetch", async () => { - stubFetch([ok("plain-body")]); - const res = await localOnlyFetch( - "http://127.0.0.1:9000/v1/models", - undefined, - "TestProvider" - ); - expect(await res.text()).toBe("plain-body"); - expect(calls).toHaveLength(1); - expect(calls[0].redirect).toBe("manual"); + expect(calls).toHaveLength(0); }); - it("does not follow a 300/304 carrying a Location header (non-standard redirect codes)", async () => { - // 300 Multiple Choices and 304 Not Modified are 3xx but are NOT standard - // redirect codes; even with a Location they are returned unchanged. The - // Location target here is non-loopback to prove it is never followed. - stubFetch([statusWithLocation(300, "http://169.254.169.254/")]); - const res = await localOnlyFetch( - "http://127.0.0.1:9000/v1/models", - undefined, - "TestProvider" - ); - expect(res.status).toBe(300); - expect(calls).toHaveLength(1); - - stubFetch([statusWithLocation(304, "http://203.0.113.10/")]); - const res2 = await localOnlyFetch( - "http://127.0.0.1:9000/v1/models", - undefined, - "TestProvider" - ); - expect(res2.status).toBe(304); - expect(calls).toHaveLength(1); + it("rejects an RFC 1918 initial URL before issuing any fetch", async () => { + stubFetch([ok("should-not-be-reached")]); + await expect( + localOnlyFetch("http://10.0.0.5/internal", undefined, "TestProvider") + ).rejects.toThrow(/non-loopback host/); + expect(calls).toHaveLength(0); }); - it("rejects a non-loopback initial URL before issuing any fetch", async () => { - // Queue a response that must never be consumed — validation happens - // before the first network call, so zero fetches are issued. + it("rejects an external initial URL before issuing any fetch", async () => { stubFetch([ok("should-not-be-reached")]); await expect( - localOnlyFetch("http://169.254.169.254/latest/meta-data/", undefined, "TestProvider") + localOnlyFetch("https://evil.example.com/steal", undefined, "TestProvider") ).rejects.toThrow(/non-loopback host/); expect(calls).toHaveLength(0); }); @@ -209,23 +161,4 @@ describe("localOnlyFetch", () => { ).rejects.toThrow(/non-HTTP\(S\)/); expect(calls).toHaveLength(0); }); - - it("throws after more than 5 chained loopback redirects", async () => { - // Queue 6 redirects: hops 0..5 (six fetches) all return a redirect, so the - // loop exhausts MAX_REDIRECTS (5) and throws on the count guard. All - // targets are loopback so the only failure mode is the redirect-count - // guard. (A 7th response is never reached and is intentionally omitted.) - stubFetch([ - redirect("http://127.0.0.1:9000/a"), - redirect("http://127.0.0.1:9000/b"), - redirect("http://127.0.0.1:9000/c"), - redirect("http://127.0.0.1:9000/d"), - redirect("http://127.0.0.1:9000/e"), - redirect("http://127.0.0.1:9000/f"), - ]); - await expect( - localOnlyFetch("http://127.0.0.1:9000/start", undefined, "TestProvider") - ).rejects.toThrow(/too many redirects/); - expect(calls).toHaveLength(6); - }); }); diff --git a/providers/llamacpp-server/src/ai/common/LlamaCppServer_Client.ts b/providers/llamacpp-server/src/ai/common/LlamaCppServer_Client.ts index 940e508c9..125e1f4a1 100644 --- a/providers/llamacpp-server/src/ai/common/LlamaCppServer_Client.ts +++ b/providers/llamacpp-server/src/ai/common/LlamaCppServer_Client.ts @@ -7,7 +7,7 @@ import { type IBackendsTransport, type IRunningHandle, - normalizeLocalHttpUrl, + normalizeLoopbackHttpUrl, } from "@workglow/ai/provider-utils"; import { LLAMACPP_SERVER_DEFAULT_CTX } from "./LlamaCppServer_Constants"; import type { LlamaCppServerModelConfig } from "./LlamaCppServer_ModelSchema"; @@ -79,14 +79,17 @@ export async function acquireBaseUrl( } /** - * Thin wrapper around the shared {@link normalizeLocalHttpUrl} helper. + * Thin wrapper around the shared {@link normalizeLoopbackHttpUrl} helper. * * Local-only validation lives in `@workglow/ai/provider-utils` so the * llama-server and stable-diffusion-server providers share one strict - * allow-list and one canonicalisation policy. + * allow-list and one canonicalisation policy. The LOOPBACK-ONLY variant is + * used so an RFC 1918 / link-local base (e.g. `http://10.0.0.5:9000`) is + * rejected at config time with a clear message rather than failing silently + * later at request time. */ export function normalizeServerBaseUrl(rawUrl: string): string { - return normalizeLocalHttpUrl(rawUrl, "LlamaCppServer"); + return normalizeLoopbackHttpUrl(rawUrl, "LlamaCppServer"); } export function buildServerUrl(baseUrl: string, endpoint: `/${string}`): string { diff --git a/providers/stable-diffusion-server/src/ai/common/StableDiffusionCpp_Client.ts b/providers/stable-diffusion-server/src/ai/common/StableDiffusionCpp_Client.ts index 6af006cc7..016eddbe4 100644 --- a/providers/stable-diffusion-server/src/ai/common/StableDiffusionCpp_Client.ts +++ b/providers/stable-diffusion-server/src/ai/common/StableDiffusionCpp_Client.ts @@ -7,7 +7,7 @@ import { type IBackendsTransport, type IRunningHandle, - normalizeLocalHttpUrl, + normalizeLoopbackHttpUrl, } from "@workglow/ai/provider-utils"; import type { StableDiffusionCppModelConfig } from "./StableDiffusionCpp_ModelSchema"; @@ -81,14 +81,17 @@ export async function acquireBaseUrl( } /** - * Thin wrapper around the shared {@link normalizeLocalHttpUrl} helper. + * Thin wrapper around the shared {@link normalizeLoopbackHttpUrl} helper. * * Local-only validation lives in `@workglow/ai/provider-utils` so the * llama-server and stable-diffusion-server providers share one strict - * allow-list and one canonicalisation policy. + * allow-list and one canonicalisation policy. The LOOPBACK-ONLY variant is + * used so an RFC 1918 / link-local base (e.g. `http://10.0.0.5:9000`) is + * rejected at config time with a clear message rather than failing silently + * later at request time. */ export function normalizeServerBaseUrl(rawUrl: string): string { - return normalizeLocalHttpUrl(rawUrl, "StableDiffusionCpp"); + return normalizeLoopbackHttpUrl(rawUrl, "StableDiffusionCpp"); } export function buildServerUrl(baseUrl: string, endpoint: `/${string}`): string {