|
1 | 1 | /** |
2 | 2 | * Scans remote provider model catalogs for configured providers. |
3 | 3 | */ |
| 4 | +import { readResponseWithLimit } from "@openclaw/media-core/read-response-with-limit"; |
4 | 5 | import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; |
5 | 6 | import { |
6 | 7 | asDateTimestampMs, |
@@ -25,6 +26,11 @@ import { inferParamBFromIdOrName } from "../shared/model-param-b.js";
|
25 | 26 | const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; |
26 | 27 | const DEFAULT_TIMEOUT_MS = 12_000; |
27 | 28 | 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; |
28 | 34 | |
29 | 35 | const BASE_IMAGE_PNG = |
30 | 36 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII="; |
@@ -184,6 +190,26 @@ async function withTimeout<T>(
|
184 | 190 | } |
185 | 191 | } |
186 | 192 | |
| 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 | + |
187 | 213 | async function fetchOpenRouterModels( |
188 | 214 | fetchImpl: typeof fetch, |
189 | 215 | timeoutMs: number, |
@@ -199,7 +225,7 @@ async function fetchOpenRouterModels(
|
199 | 225 | if (!res.ok) { |
200 | 226 | throw new Error(`OpenRouter /models failed: HTTP ${res.status}`); |
201 | 227 | } |
202 | | -const payload = (await res.json()) as { data?: unknown }; |
| 228 | +const payload = (await readOpenRouterModelsJson(res, timeoutMs)) as { data?: unknown }; |
203 | 229 | const entries = Array.isArray(payload.data) ? payload.data : []; |
204 | 230 | |
205 | 231 | return entries |
|