From 06ca1235efb793b1b253455531f7300a2804a50e Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Tue, 23 Jun 2026 11:10:15 +0800
Subject: [PATCH] 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.
---
src/agents/model-scan.test.ts | 51 +++++++++++++++++++++++++++++++++++
src/agents/model-scan.ts | 28 ++++++++++++++++++-
2 files changed, 78 insertions(+), 1 deletion(-)
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