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 <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
This commit is contained in:
Alix-007
2026-06-25 05:51:14 +08:00
committed by GitHub
parent 170bf72e64
commit dad5ce64d4
2 changed files with 135 additions and 2 deletions
@@ -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<Uint8Array>;
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<Uint8Array>({
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", () => {
+30 -2
View File
@@ -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<unknown> {
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<void> {
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`);