Skip to content

Commit ee6d72f

Browse files
committed
fix(agents): bound OpenRouter model catalog response reads
The runtime OpenRouter model-capability detector fetched the full /models catalog with an unbounded `await response.json()`, so a compromised or misbehaving endpoint could stream an arbitrarily large body and force the process to buffer the whole payload before parsing. Read the body through the shared bounded reader instead, capping it at 16 MiB (matching the sibling pricing-cache endpoint hardened in #95103) and cancelling the stream on overflow. This mirrors the symmetric bound-stream fixes in #95103 and #95108. Adds coverage that an oversized streamed catalog is cancelled instead of buffered and that an under-cap chunked body still reassembles, parses, and round-trips through the SQLite cache on a fresh import.
1 parent 7217477 commit ee6d72f

2 files changed

Lines changed: 106 additions & 1 deletion

File tree

src/agents/embedded-agent-runner/openrouter-model-capabilities.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,103 @@ describe("openrouter-model-capabilities", () => {
312312
});
313313
});
314314

315+
it("bounds an oversized streamed OpenRouter catalog instead of buffering it whole", async () => {
316+
await withOpenRouterStateDir(async () => {
317+
// First pull emits a chunk larger than the cap; a well-behaved bounded read
318+
// must cancel before requesting the (effectively infinite) second chunk.
319+
let pullCount = 0;
320+
const cancel = vi.fn(async () => undefined);
321+
const stream = new ReadableStream<Uint8Array>({
322+
pull(controller) {
323+
pullCount += 1;
324+
controller.enqueue(new Uint8Array(pullCount === 1 ? 16 * 1024 * 1024 + 1 : 1));
325+
},
326+
cancel,
327+
});
328+
const fetchSpy = vi.fn(
329+
async () =>
330+
new Response(stream, {
331+
status: 200,
332+
headers: { "content-type": "application/json" },
333+
}),
334+
);
335+
vi.stubGlobal("fetch", fetchSpy);
336+
337+
const module = await importOpenRouterModelCapabilities("oversized-stream");
338+
await module.loadOpenRouterModelCapabilities("acme/anything");
339+
340+
// The body was cancelled after the first oversized chunk rather than read
341+
// to completion, and the overflow left no poisoned cache entry behind.
342+
expect(fetchSpy).toHaveBeenCalledTimes(1);
343+
expect(pullCount).toBeLessThanOrEqual(2);
344+
expect(cancel).toHaveBeenCalledOnce();
345+
expect(module.getOpenRouterModelCapabilities("acme/anything")).toBeUndefined();
346+
});
347+
});
348+
349+
it("round-trips a chunked under-cap catalog through the SQLite cache", async () => {
350+
await withOpenRouterStateDir(async () => {
351+
// Stream the payload across several small chunks so the bounded reader has to
352+
// reassemble it; the reassembled bytes must parse and survive a cross-import
353+
// SQLite read-back identical to the source catalog.
354+
const payload = JSON.stringify({
355+
data: [
356+
{
357+
id: "acme/chunked-model",
358+
name: "Chunked Model",
359+
architecture: { modality: "text+image->text" },
360+
supported_parameters: ["reasoning", "tools"],
361+
context_length: 13579,
362+
max_completion_tokens: 2468,
363+
pricing: { prompt: "0.000007", completion: "0.000008" },
364+
},
365+
],
366+
});
367+
const encoded = new TextEncoder().encode(payload);
368+
const fetchSpy = vi.fn(async () => {
369+
let offset = 0;
370+
const stream = new ReadableStream<Uint8Array>({
371+
pull(controller) {
372+
if (offset >= encoded.length) {
373+
controller.close();
374+
return;
375+
}
376+
const end = Math.min(offset + 8, encoded.length);
377+
controller.enqueue(encoded.subarray(offset, end));
378+
offset = end;
379+
},
380+
});
381+
return new Response(stream, {
382+
status: 200,
383+
headers: { "content-type": "application/json" },
384+
});
385+
});
386+
vi.stubGlobal("fetch", fetchSpy);
387+
388+
const writer = await importOpenRouterModelCapabilities("chunked-sqlite-writer");
389+
await writer.loadOpenRouterModelCapabilities("acme/chunked-model");
390+
expect(fetchSpy).toHaveBeenCalledTimes(1);
391+
expect(writer.getOpenRouterModelCapabilities("acme/chunked-model")).toMatchObject({
392+
input: ["text", "image"],
393+
reasoning: true,
394+
supportsTools: true,
395+
contextWindow: 13579,
396+
maxTokens: 2468,
397+
});
398+
399+
// Fresh import reads only from the SQLite cache the bounded read populated.
400+
const reader = await importOpenRouterModelCapabilities("chunked-sqlite-reader");
401+
expect(reader.getOpenRouterModelCapabilities("acme/chunked-model")).toMatchObject({
402+
input: ["text", "image"],
403+
reasoning: true,
404+
supportsTools: true,
405+
contextWindow: 13579,
406+
maxTokens: 2468,
407+
});
408+
expect(fetchSpy).toHaveBeenCalledTimes(1);
409+
});
410+
});
411+
315412
it("does not refetch immediately after an awaited miss for the same model id", async () => {
316413
await withOpenRouterStateDir(async () => {
317414
const fetchSpy = vi.fn(

src/agents/embedded-agent-runner/openrouter-model-capabilities.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
* capabilities instead of the text-only fallback.
1919
*/
2020

21+
import { readResponseWithLimit } from "@openclaw/media-core/read-response-with-limit";
2122
import { formatErrorMessage } from "../../infra/errors.js";
2223
import { resolveProxyFetchFromEnv } from "../../infra/net/proxy-fetch.js";
2324
import { parseStrictFiniteNumber } from "../../infra/parse-finite-number.js";
@@ -28,6 +29,10 @@ const log = createSubsystemLogger("openrouter-model-capabilities");
2829

2930
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
3031
const FETCH_TIMEOUT_MS = 10_000;
32+
// Cap the catalog body so an untrusted/oversized OpenRouter response cannot force
33+
// the runtime to buffer an unbounded payload before parsing. Mirrors the bound
34+
// applied to the sibling pricing-cache endpoint (16 MiB).
35+
const OPENROUTER_MODELS_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
3136
const SQLITE_CACHE_OWNER_ID = "core:openrouter-model-capabilities";
3237
const SQLITE_CACHE_NAMESPACE = "models.v3";
3338
const SQLITE_CACHE_MAX_ENTRIES = 10_000;
@@ -198,7 +203,10 @@ async function doFetch(): Promise<void> {
198203
return;
199204
}
200205

201-
const data = (await response.json()) as { data?: OpenRouterApiModel[] };
206+
const bytes = await readResponseWithLimit(response, OPENROUTER_MODELS_RESPONSE_MAX_BYTES, {
207+
onOverflow: ({ size }) => new Error(`OpenRouter models response too large: ${size} bytes`),
208+
});
209+
const data = JSON.parse(bytes.toString("utf8")) as { data?: OpenRouterApiModel[] };
202210
const models = data.data ?? [];
203211
const map = new Map<string, OpenRouterModelCapabilities>();
204212

0 commit comments

Comments
 (0)