mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user