fix(agents): bound OpenRouter model-scan catalog success body (#95418)

The OpenRouter /models catalog read in fetchOpenRouterModels hardened only
the error/early-return path (dbd5689 cancels the body when res.bodyUsed is
false), but the success branch still buffered the whole body with an
unbounded `await res.json()`. The response is a provider-controlled,
runtime-fetched body, so a faulty or hostile provider can stream an
effectively unbounded JSON document and exhaust process memory before the
parse completes; the finally-cancel is a no-op once .json() has drained.

Read the success body through the canonical byte-cap reader
(readResponseWithLimit) under a 4 MiB ceiling before JSON.parse, cancelling
the stream on overflow and bounding idle stalls with the call's existing
timeout. This is the symmetric success-path counterpart to the bounded-stream
hardening landed in #95103 (pricing catalog) and #95108 (Anthropic error
streams), reusing the same helper rather than a new abstraction.
This commit is contained in:
Alix-007
2026-06-23 11:10:15 +08:00
committed by GitHub
parent 3da4280caf
commit 06ca1235ef
2 changed files with 78 additions and 1 deletions

View File

@@ -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 () => {

View File

@@ -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<T>(
}
}
// 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<unknown> {
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