mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-30 14:43:40 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user