From dad5ce64d4f1ba35e8f4cff73ddd3d704d4c5898 Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Thu, 25 Jun 2026 05:51:14 +0800
Subject: [PATCH] fix(providers): bound self-hosted provider discovery JSON
reads (#95244)
* fix(providers): bound self-hosted discovery JSON reads
discoverLlamaCppRuntimeContextTokens and discoverOpenAICompatibleLocalModels
parsed their HTTP responses via an unbounded await response.json(). Self-hosted
provider base URLs are user-supplied and untrusted (an endpoint reachable via
SSRF could stream an unbounded JSON body), so a hostile or buggy endpoint could
drive the setup wizard into OOM.
Route both reads through the shared byte-bounded reader (readResponseWithLimit
from @openclaw/media-core) under a single 4 MiB cap before JSON.parse, mirroring
the bound-stream hardening landed for Anthropic error bodies. Overflow cancels
the stream and is swallowed by the existing discovery error handling, so a
capped endpoint degrades gracefully (returns [] / skips the runtime context
probe) instead of buffering the whole body.
* tune self-hosted discovery cap
Signed-off-by: sallyom
---------
Signed-off-by: sallyom
Co-authored-by: sallyom
---
.../provider-self-hosted-setup.test.ts | 105 ++++++++++++++++++
src/plugins/provider-self-hosted-setup.ts | 32 +++++-
2 files changed, 135 insertions(+), 2 deletions(-)
diff --git a/src/plugins/provider-self-hosted-setup.test.ts b/src/plugins/provider-self-hosted-setup.test.ts
index d64299924384..e755848757ed 100644
--- a/src/plugins/provider-self-hosted-setup.test.ts
+++ b/src/plugins/provider-self-hosted-setup.test.ts
@@ -23,6 +23,42 @@ beforeEach(() => {
vi.clearAllMocks();
});
+// Mirrors SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES in the source under test. Kept in
+// sync deliberately so the regression asserts the body is capped, not drained.
+const SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES = 16 * 1024 * 1024;
+const CHUNK_BYTES = 1024 * 1024;
+
+/**
+ * Builds a Response body that would never terminate on its own: each pull emits
+ * a 1 MiB chunk forever. A bounded reader must cancel it after the byte cap.
+ */
+function createUnboundedJsonStream(): {
+ body: ReadableStream;
+ cancelCount: number;
+ bytesPulled: number;
+} {
+ const state = { cancelCount: 0, bytesPulled: 0 };
+ const chunk = new Uint8Array(CHUNK_BYTES).fill(0x20); // ASCII spaces: valid stream, never closes
+ const body = new ReadableStream({
+ pull(controller) {
+ state.bytesPulled += chunk.byteLength;
+ controller.enqueue(chunk);
+ },
+ cancel() {
+ state.cancelCount += 1;
+ },
+ });
+ return {
+ body,
+ get cancelCount() {
+ return state.cancelCount;
+ },
+ get bytesPulled() {
+ return state.bytesPulled;
+ },
+ };
+}
+
function createRuntime() {
return {
error: vi.fn(),
@@ -437,6 +473,75 @@ describe("discoverOpenAICompatibleLocalModels", () => {
});
expect(release).toHaveBeenCalledOnce();
});
+
+ it("bounds an unbounded /models discovery stream instead of buffering it", async () => {
+ const release = vi.fn(async () => undefined);
+ const oversized = createUnboundedJsonStream();
+ fetchWithSsrFGuardMock.mockResolvedValueOnce({
+ response: new Response(oversized.body, { status: 200 }),
+ finalUrl: "http://127.0.0.1:8000/v1/models",
+ release,
+ });
+
+ const models = await discoverOpenAICompatibleLocalModels({
+ baseUrl: "http://127.0.0.1:8000/v1",
+ label: "vLLM",
+ env: {},
+ });
+
+ // The reader cancels the body once the byte cap is exceeded; without the
+ // cap the stream would never finish and the discovery would buffer it all.
+ expect(models).toEqual([]);
+ expect(oversized.cancelCount).toBe(1);
+ expect(oversized.bytesPulled).toBeLessThanOrEqual(
+ SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES + 2 * CHUNK_BYTES,
+ );
+ expect(release).toHaveBeenCalledOnce();
+ });
+
+ it("bounds an unbounded llama.cpp /props discovery stream instead of buffering it", async () => {
+ const modelsRelease = vi.fn(async () => undefined);
+ const propsRelease = vi.fn(async () => undefined);
+ const oversized = createUnboundedJsonStream();
+ fetchWithSsrFGuardMock.mockResolvedValueOnce({
+ response: new Response(JSON.stringify({ data: [{ id: "qwen3.6-mxfp4-moe" }] }), {
+ status: 200,
+ }),
+ finalUrl: "http://127.0.0.1:8080/v1/models",
+ release: modelsRelease,
+ });
+ fetchWithSsrFGuardMock.mockResolvedValueOnce({
+ response: new Response(oversized.body, { status: 200 }),
+ finalUrl: "http://127.0.0.1:8080/props",
+ release: propsRelease,
+ });
+
+ const models = await discoverOpenAICompatibleLocalModels({
+ baseUrl: "http://127.0.0.1:8080/v1",
+ label: "llama.cpp",
+ env: {},
+ });
+
+ // /props overflow is swallowed so discovery still succeeds, but the body is
+ // capped: the runtime context token probe is skipped, not OOM'd.
+ expect(models).toEqual([
+ {
+ id: "qwen3.6-mxfp4-moe",
+ name: "qwen3.6-mxfp4-moe",
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 128000,
+ maxTokens: 8192,
+ },
+ ]);
+ expect(oversized.cancelCount).toBe(1);
+ expect(oversized.bytesPulled).toBeLessThanOrEqual(
+ SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES + 2 * CHUNK_BYTES,
+ );
+ expect(modelsRelease).toHaveBeenCalledOnce();
+ expect(propsRelease).toHaveBeenCalledOnce();
+ });
});
describe("configureOpenAICompatibleSelfHostedProviderNonInteractive", () => {
diff --git a/src/plugins/provider-self-hosted-setup.ts b/src/plugins/provider-self-hosted-setup.ts
index f66f8ef9fea8..fa6980086247 100644
--- a/src/plugins/provider-self-hosted-setup.ts
+++ b/src/plugins/provider-self-hosted-setup.ts
@@ -1,4 +1,5 @@
// Builds setup metadata for self-hosted provider plugins.
+import { readResponseWithLimit } from "@openclaw/media-core/read-response-with-limit";
import {
findNormalizedProviderValue,
normalizeProviderId,
@@ -39,6 +40,12 @@ export {
const log = createSubsystemLogger("plugins/self-hosted-provider-setup");
+// Self-hosted provider base URLs are user-supplied and untrusted (an attacker
+// who can influence the configured endpoint, e.g. via SSRF, could serve an
+// unbounded JSON stream). Cap discovery response bodies before parsing so a
+// hostile or buggy endpoint cannot drive the setup wizard into OOM.
+const SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES = 16 * 1024 * 1024;
+
type OpenAICompatModelsResponse = {
data?: Array<{
id?: string;
@@ -86,6 +93,21 @@ function readPositiveInteger(value: unknown): number | undefined {
return Math.trunc(value);
}
+/**
+ * Reads and parses a self-hosted discovery JSON body under a hard byte cap.
+ * Mirrors the byte-bounded reader pattern shared across provider/media reads so
+ * an untrusted endpoint cannot stream an unbounded body into memory.
+ */
+async function readSelfHostedDiscoveryJson(response: Response, label: string): Promise {
+ const bytes = await readResponseWithLimit(response, SELF_HOSTED_DISCOVERY_JSON_MAX_BYTES, {
+ onOverflow: ({ size, maxBytes }) =>
+ new Error(
+ `${label} discovery response body too large: ${size} bytes (limit: ${maxBytes} bytes)`,
+ ),
+ });
+ return JSON.parse(new TextDecoder().decode(bytes));
+}
+
async function cancelUnreadResponseBody(response: Response): Promise {
if (!response.bodyUsed) {
await response.body?.cancel().catch(() => undefined);
@@ -133,7 +155,10 @@ async function discoverLlamaCppRuntimeContextTokens(params: {
await cancelUnreadResponseBody(response);
return undefined;
}
- const data = (await response.json()) as LlamaCppPropsResponse;
+ const data = (await readSelfHostedDiscoveryJson(
+ response,
+ "llama.cpp /props",
+ )) as LlamaCppPropsResponse;
return (
readPositiveInteger(data.default_generation_settings?.n_ctx) ??
readPositiveInteger(data.n_ctx)
@@ -178,7 +203,10 @@ export async function discoverOpenAICompatibleLocalModels(params: {
log.warn(`Failed to discover ${params.label} models: ${response.status}`);
return [];
}
- const data = (await response.json()) as OpenAICompatModelsResponse;
+ const data = (await readSelfHostedDiscoveryJson(
+ response,
+ params.label,
+ )) as OpenAICompatModelsResponse;
const models = data.data ?? [];
if (models.length === 0) {
log.warn(`No ${params.label} models found on local instance`);