diff --git a/src/agents/model-scan.test.ts b/src/agents/model-scan.test.ts index b9c15f7a9ad..60bd80dafd0 100644 --- a/src/agents/model-scan.test.ts +++ b/src/agents/model-scan.test.ts @@ -113,6 +113,57 @@ describe("scanOpenRouterModels", () => { expect(cancel).toHaveBeenCalledOnce(); }); + it("bounds an oversized catalog success body and cancels the stream", async () => { + // The success body is provider-controlled and runtime-fetched. A faulty or + // hostile provider can stream an effectively unbounded JSON document; the + // read must stop at the byte cap, cancel the upstream stream, and surface a + // clear overflow error instead of buffering the whole payload into memory. + const cancel = vi.fn(async () => undefined); + let pullCount = 0; + const chunk = new Uint8Array(64 * 1024).fill(0x20); // 64 KiB of spaces + const fetchImpl = withFetchPreconnect( + async () => + new Response( + new ReadableStream({ + start(controller) { + // Valid JSON array prefix so the body would parse if ever read in full. + controller.enqueue(new TextEncoder().encode('{"data":[')); + }, + pull(controller) { + pullCount += 1; + controller.enqueue(chunk); + }, + cancel, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + await expect(scanOpenRouterModels({ fetchImpl, probe: false })).rejects.toThrow( + /OpenRouter \/models response too large/, + ); + + // The reader stopped early instead of draining an unbounded stream, and + // cancelled the upstream body once the byte cap was crossed. + expect(cancel).toHaveBeenCalledOnce(); + expect(pullCount).toBeGreaterThan(0); + expect(pullCount).toBeLessThan(4 * 1024 * 1024); // nowhere near draining forever + }); + + it("rejects a malformed catalog success body", async () => { + const fetchImpl = withFetchPreconnect( + async () => + new Response("not json at all", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + await expect(scanOpenRouterModels({ fetchImpl, probe: false })).rejects.toThrow( + /OpenRouter \/models response is malformed JSON/, + ); + }); + it("requires an API key when probing", async () => { const fetchImpl = createFetchFixture({ data: [] }); await withEnvAsync({ OPENROUTER_API_KEY: undefined }, async () => { diff --git a/src/agents/model-scan.ts b/src/agents/model-scan.ts index e9a543e6339..be16d1055ad 100644 --- a/src/agents/model-scan.ts +++ b/src/agents/model-scan.ts @@ -1,6 +1,7 @@ /** * Scans remote provider model catalogs for configured providers. */ +import { readResponseWithLimit } from "@openclaw/media-core/read-response-with-limit"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { asDateTimestampMs, @@ -25,6 +26,11 @@ import { inferParamBFromIdOrName } from "../shared/model-param-b.js"; const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; const DEFAULT_TIMEOUT_MS = 12_000; const DEFAULT_CONCURRENCY = 3; +// The OpenRouter /models catalog is a provider-controlled, runtime-fetched body +// (already >100 KB and growing). Read it under a byte cap before JSON.parse so a +// faulty or hostile provider cannot stream an unbounded document and exhaust +// process memory. 4 MiB matches the cap used for the same endpoint elsewhere. +const OPENROUTER_MODELS_BODY_MAX_BYTES = 4 * 1024 * 1024; const BASE_IMAGE_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII="; @@ -184,6 +190,26 @@ async function withTimeout( } } +// Reads the OpenRouter /models success body under a byte cap before JSON.parse. +// The success path was previously buffered with an unbounded res.json(); a faulty +// or hostile provider could stream an effectively endless document and exhaust +// memory. readResponseWithLimit caps the read, cancels the stream on overflow, +// and bounds idle stalls with the call's existing timeout. +async function readOpenRouterModelsJson(response: Response, timeoutMs: number): Promise { + const buffer = await readResponseWithLimit(response, OPENROUTER_MODELS_BODY_MAX_BYTES, { + chunkTimeoutMs: timeoutMs, + onOverflow: ({ size, maxBytes }) => + new Error(`OpenRouter /models response too large: ${size} bytes (limit ${maxBytes} bytes)`), + onIdleTimeout: ({ chunkTimeoutMs }) => + new Error(`OpenRouter /models response stalled after ${chunkTimeoutMs}ms`), + }); + try { + return JSON.parse(buffer.toString("utf8")) as unknown; + } catch (cause) { + throw new Error("OpenRouter /models response is malformed JSON", { cause }); + } +} + async function fetchOpenRouterModels( fetchImpl: typeof fetch, timeoutMs: number, @@ -199,7 +225,7 @@ async function fetchOpenRouterModels( if (!res.ok) { throw new Error(`OpenRouter /models failed: HTTP ${res.status}`); } - const payload = (await res.json()) as { data?: unknown }; + const payload = (await readOpenRouterModelsJson(res, timeoutMs)) as { data?: unknown }; const entries = Array.isArray(payload.data) ? payload.data : []; return entries