diff --git a/src/plugins/provider-self-hosted-setup.test.ts b/src/plugins/provider-self-hosted-setup.test.ts index d64299924384..e755848757ed 100644 --- a/src/plugins/provider-self-hosted-setup.test.ts +++ b/src/plugins/provider-self-hosted-setup.test.ts @@ -23,6 +23,42 @@ beforeEach(() => { vi.clearAllMocks(); }); +// Mirrors SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES in the source under test. Kept in +// sync deliberately so the regression asserts the body is capped, not drained. +const SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES = 16 * 1024 * 1024; +const CHUNK_BYTES = 1024 * 1024; + +/** + * Builds a Response body that would never terminate on its own: each pull emits + * a 1 MiB chunk forever. A bounded reader must cancel it after the byte cap. + */ +function createUnboundedJsonStream(): { + body: ReadableStream; + cancelCount: number; + bytesPulled: number; +} { + const state = { cancelCount: 0, bytesPulled: 0 }; + const chunk = new Uint8Array(CHUNK_BYTES).fill(0x20); // ASCII spaces: valid stream, never closes + const body = new ReadableStream({ + pull(controller) { + state.bytesPulled += chunk.byteLength; + controller.enqueue(chunk); + }, + cancel() { + state.cancelCount += 1; + }, + }); + return { + body, + get cancelCount() { + return state.cancelCount; + }, + get bytesPulled() { + return state.bytesPulled; + }, + }; +} + function createRuntime() { return { error: vi.fn(), @@ -437,6 +473,75 @@ describe("discoverOpenAICompatibleLocalModels", () => { }); expect(release).toHaveBeenCalledOnce(); }); + + it("bounds an unbounded /models discovery stream instead of buffering it", async () => { + const release = vi.fn(async () => undefined); + const oversized = createUnboundedJsonStream(); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(oversized.body, { status: 200 }), + finalUrl: "http://127.0.0.1:8000/v1/models", + release, + }); + + const models = await discoverOpenAICompatibleLocalModels({ + baseUrl: "http://127.0.0.1:8000/v1", + label: "vLLM", + env: {}, + }); + + // The reader cancels the body once the byte cap is exceeded; without the + // cap the stream would never finish and the discovery would buffer it all. + expect(models).toEqual([]); + expect(oversized.cancelCount).toBe(1); + expect(oversized.bytesPulled).toBeLessThanOrEqual( + SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES + 2 * CHUNK_BYTES, + ); + expect(release).toHaveBeenCalledOnce(); + }); + + it("bounds an unbounded llama.cpp /props discovery stream instead of buffering it", async () => { + const modelsRelease = vi.fn(async () => undefined); + const propsRelease = vi.fn(async () => undefined); + const oversized = createUnboundedJsonStream(); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(JSON.stringify({ data: [{ id: "qwen3.6-mxfp4-moe" }] }), { + status: 200, + }), + finalUrl: "http://127.0.0.1:8080/v1/models", + release: modelsRelease, + }); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(oversized.body, { status: 200 }), + finalUrl: "http://127.0.0.1:8080/props", + release: propsRelease, + }); + + const models = await discoverOpenAICompatibleLocalModels({ + baseUrl: "http://127.0.0.1:8080/v1", + label: "llama.cpp", + env: {}, + }); + + // /props overflow is swallowed so discovery still succeeds, but the body is + // capped: the runtime context token probe is skipped, not OOM'd. + expect(models).toEqual([ + { + id: "qwen3.6-mxfp4-moe", + name: "qwen3.6-mxfp4-moe", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }, + ]); + expect(oversized.cancelCount).toBe(1); + expect(oversized.bytesPulled).toBeLessThanOrEqual( + SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES + 2 * CHUNK_BYTES, + ); + expect(modelsRelease).toHaveBeenCalledOnce(); + expect(propsRelease).toHaveBeenCalledOnce(); + }); }); describe("configureOpenAICompatibleSelfHostedProviderNonInteractive", () => { diff --git a/src/plugins/provider-self-hosted-setup.ts b/src/plugins/provider-self-hosted-setup.ts index f66f8ef9fea8..fa6980086247 100644 --- a/src/plugins/provider-self-hosted-setup.ts +++ b/src/plugins/provider-self-hosted-setup.ts @@ -1,4 +1,5 @@ // Builds setup metadata for self-hosted provider plugins. +import { readResponseWithLimit } from "@openclaw/media-core/read-response-with-limit"; import { findNormalizedProviderValue, normalizeProviderId, @@ -39,6 +40,12 @@ export { const log = createSubsystemLogger("plugins/self-hosted-provider-setup"); +// Self-hosted provider base URLs are user-supplied and untrusted (an attacker +// who can influence the configured endpoint, e.g. via SSRF, could serve an +// unbounded JSON stream). Cap discovery response bodies before parsing so a +// hostile or buggy endpoint cannot drive the setup wizard into OOM. +const SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES = 16 * 1024 * 1024; + type OpenAICompatModelsResponse = { data?: Array<{ id?: string; @@ -86,6 +93,21 @@ function readPositiveInteger(value: unknown): number | undefined { return Math.trunc(value); } +/** + * Reads and parses a self-hosted discovery JSON body under a hard byte cap. + * Mirrors the byte-bounded reader pattern shared across provider/media reads so + * an untrusted endpoint cannot stream an unbounded body into memory. + */ +async function readSelfHostedDiscoveryJson(response: Response, label: string): Promise { + const bytes = await readResponseWithLimit(response, SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES, { + onOverflow: ({ size, maxBytes }) => + new Error( + `${label} discovery response body too large: ${size} bytes (limit: ${maxBytes} bytes)`, + ), + }); + return JSON.parse(new TextDecoder().decode(bytes)); +} + async function cancelUnreadResponseBody(response: Response): Promise { if (!response.bodyUsed) { await response.body?.cancel().catch(() => undefined); @@ -133,7 +155,10 @@ async function discoverLlamaCppRuntimeContextTokens(params: { await cancelUnreadResponseBody(response); return undefined; } - const data = (await response.json()) as LlamaCppPropsResponse; + const data = (await readSelfHostedDiscoveryJson( + response, + "llama.cpp /props", + )) as LlamaCppPropsResponse; return ( readPositiveInteger(data.default_generation_settings?.n_ctx) ?? readPositiveInteger(data.n_ctx) @@ -178,7 +203,10 @@ export async function discoverOpenAICompatibleLocalModels(params: { log.warn(`Failed to discover ${params.label} models: ${response.status}`); return []; } - const data = (await response.json()) as OpenAICompatModelsResponse; + const data = (await readSelfHostedDiscoveryJson( + response, + params.label, + )) as OpenAICompatModelsResponse; const models = data.data ?? []; if (models.length === 0) { log.warn(`No ${params.label} models found on local instance`);