Skip to content

Commit 285839b

Browse files
committed
fix(agents): bound OpenRouter model-scan catalog success body
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.
1 parent 7217477 commit 285839b

2 files changed

Lines changed: 78 additions & 1 deletion

File tree

src/agents/model-scan.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,57 @@ describe("scanOpenRouterModels", () => {
113113
expect(cancel).toHaveBeenCalledOnce();
114114
});
115115

116+
it("bounds an oversized catalog success body and cancels the stream", async () => {
117+
// The success body is provider-controlled and runtime-fetched. A faulty or
118+
// hostile provider can stream an effectively unbounded JSON document; the
119+
// read must stop at the byte cap, cancel the upstream stream, and surface a
120+
// clear overflow error instead of buffering the whole payload into memory.
121+
const cancel = vi.fn(async () => undefined);
122+
let pullCount = 0;
123+
const chunk = new Uint8Array(64 * 1024).fill(0x20); // 64 KiB of spaces
124+
const fetchImpl = withFetchPreconnect(
125+
async () =>
126+
new Response(
127+
new ReadableStream({
128+
start(controller) {
129+
// Valid JSON array prefix so the body would parse if ever read in full.
130+
controller.enqueue(new TextEncoder().encode('{"data":['));
131+
},
132+
pull(controller) {
133+
pullCount += 1;
134+
controller.enqueue(chunk);
135+
},
136+
cancel,
137+
}),
138+
{ status: 200, headers: { "content-type": "application/json" } },
139+
),
140+
);
141+
142+
await expect(scanOpenRouterModels({ fetchImpl, probe: false })).rejects.toThrow(
143+
/OpenRouter \/models response too large/,
144+
);
145+
146+
// The reader stopped early instead of draining an unbounded stream, and
147+
// cancelled the upstream body once the byte cap was crossed.
148+
expect(cancel).toHaveBeenCalledOnce();
149+
expect(pullCount).toBeGreaterThan(0);
150+
expect(pullCount).toBeLessThan(4 * 1024 * 1024); // nowhere near draining forever
151+
});
152+
153+
it("rejects a malformed catalog success body", async () => {
154+
const fetchImpl = withFetchPreconnect(
155+
async () =>
156+
new Response("not json at all", {
157+
status: 200,
158+
headers: { "content-type": "application/json" },
159+
}),
160+
);
161+
162+
await expect(scanOpenRouterModels({ fetchImpl, probe: false })).rejects.toThrow(
163+
/OpenRouter \/models response is malformed JSON/,
164+
);
165+
});
166+
116167
it("requires an API key when probing", async () => {
117168
const fetchImpl = createFetchFixture({ data: [] });
118169
await withEnvAsync({ OPENROUTER_API_KEY: undefined }, async () => {

src/agents/model-scan.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* Scans remote provider model catalogs for configured providers.
33
*/
4+
import { readResponseWithLimit } from "@openclaw/media-core/read-response-with-limit";
45
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
56
import {
67
asDateTimestampMs,
@@ -25,6 +26,11 @@ import { inferParamBFromIdOrName } from "../shared/model-param-b.js";
2526
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
2627
const DEFAULT_TIMEOUT_MS = 12_000;
2728
const DEFAULT_CONCURRENCY = 3;
29+
// The OpenRouter /models catalog is a provider-controlled, runtime-fetched body
30+
// (already >100 KB and growing). Read it under a byte cap before JSON.parse so a
31+
// faulty or hostile provider cannot stream an unbounded document and exhaust
32+
// process memory. 4 MiB matches the cap used for the same endpoint elsewhere.
33+
const OPENROUTER_MODELS_BODY_MAX_BYTES = 4 * 1024 * 1024;
2834

2935
const BASE_IMAGE_PNG =
3036
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII=";
@@ -184,6 +190,26 @@ async function withTimeout<T>(
184190
}
185191
}
186192

193+
// Reads the OpenRouter /models success body under a byte cap before JSON.parse.
194+
// The success path was previously buffered with an unbounded res.json(); a faulty
195+
// or hostile provider could stream an effectively endless document and exhaust
196+
// memory. readResponseWithLimit caps the read, cancels the stream on overflow,
197+
// and bounds idle stalls with the call's existing timeout.
198+
async function readOpenRouterModelsJson(response: Response, timeoutMs: number): Promise<unknown> {
199+
const buffer = await readResponseWithLimit(response, OPENROUTER_MODELS_BODY_MAX_BYTES, {
200+
chunkTimeoutMs: timeoutMs,
201+
onOverflow: ({ size, maxBytes }) =>
202+
new Error(`OpenRouter /models response too large: ${size} bytes (limit ${maxBytes} bytes)`),
203+
onIdleTimeout: ({ chunkTimeoutMs }) =>
204+
new Error(`OpenRouter /models response stalled after ${chunkTimeoutMs}ms`),
205+
});
206+
try {
207+
return JSON.parse(buffer.toString("utf8")) as unknown;
208+
} catch (cause) {
209+
throw new Error("OpenRouter /models response is malformed JSON", { cause });
210+
}
211+
}
212+
187213
async function fetchOpenRouterModels(
188214
fetchImpl: typeof fetch,
189215
timeoutMs: number,
@@ -199,7 +225,7 @@ async function fetchOpenRouterModels(
199225
if (!res.ok) {
200226
throw new Error(`OpenRouter /models failed: HTTP ${res.status}`);
201227
}
202-
const payload = (await res.json()) as { data?: unknown };
228+
const payload = (await readOpenRouterModelsJson(res, timeoutMs)) as { data?: unknown };
203229
const entries = Array.isArray(payload.data) ? payload.data : [];
204230

205231
return entries

0 commit comments

Comments
 (0)