fix(lmstudio): bound model load success response body to prevent OOM (#96042)

The /api/v1/models/load success path read the response with an unbounded
await response.json(), so a misbehaving or compromised LM Studio server
could stream an arbitrarily large JSON body that is fully buffered into
memory before any size check. Read it through the shared byte-capped
readProviderJsonResponse helper instead (16 MiB provider-JSON cap, cancels
the stream on overflow, wraps malformed JSON), matching the discovery path
and the already-bounded error body.

Migrate the model fetch/load test mocks to real Response objects (the
bounded readers need a real body stream) and add a regression test that
streams an oversized success body and asserts a bounded error plus stream
cancellation.

Label: security
This commit is contained in:
Alix-007
2026-06-24 21:03:02 +08:00
committed by GitHub
parent ae9474b5fd
commit 7844b08445
2 changed files with 71 additions and 7 deletions

View File

@@ -3,6 +3,7 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import {
readProviderJsonArrayFieldResponse,
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
@@ -285,12 +286,13 @@ export async function ensureLmstudioModelLoaded(params: {
`LM Studio model load failed (${response.status})${body ? `: ${body}` : ""}`,
);
}
let payload: LmstudioLoadResponse;
try {
payload = (await response.json()) as LmstudioLoadResponse;
} catch (cause) {
throw new Error("LM Studio model load returned malformed JSON", { cause });
}
// Read the success body through the shared byte-capped reader so a misbehaving
// or compromised LM Studio server cannot stream an unbounded JSON payload into
// memory before we parse it. Malformed JSON is wrapped with our own label.
const payload = await readProviderJsonResponse<LmstudioLoadResponse>(
response,
"LM Studio model load",
);
if (typeof payload.status === "string" && payload.status.toLowerCase() !== "loaded") {
throw new Error(`LM Studio model load returned unexpected status: ${payload.status}`);
}

View File

@@ -59,6 +59,21 @@ describe("lmstudio-models", () => {
}
return JSON.parse(init.body) as unknown;
};
// The model fetch/load helpers now read bodies through the shared byte-capped
// reader, so success-path mocks must be real Response objects with a body
// stream rather than bare `{ ok, json }` placeholders.
const jsonResponse = (payload: unknown, init?: ResponseInit): Response =>
new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
const malformedJsonResponse = (init?: ResponseInit): Response =>
new Response("{ this is not valid json", {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
const cancelTrackedResponse = (
text: string,
init: ResponseInit,
@@ -89,6 +104,7 @@ describe("lmstudio-models", () => {
}) =>
vi.fn(async (url: string | URL, _init?: RequestInit) => {
const key = params?.key ?? "qwen3-8b-instruct";
void init;
if (String(url).endsWith("/api/v1/models")) {
return jsonResponse({
models: [
@@ -582,7 +598,53 @@ describe("lmstudio-models", () => {
baseUrl: "http://localhost:1234/v1",
modelKey: "qwen3-8b-instruct",
}),
).rejects.toThrow("LM Studio model load returned malformed JSON");
).rejects.toThrow("LM Studio model load: malformed JSON response");
});
it("bounds oversized model load success bodies", async () => {
// A misbehaving server may stream an unbounded success JSON body; the load
// path must stop reading at the byte cap instead of buffering it all.
let canceled = false;
let bytesEmitted = 0;
const oversizedStream = new ReadableStream<Uint8Array>({
pull(controller) {
// Far exceeds the 16 MiB provider JSON cap if read to completion.
if (bytesEmitted >= 32 * 1024 * 1024) {
controller.close();
return;
}
bytesEmitted += 64 * 1024;
controller.enqueue(new Uint8Array(64 * 1024).fill(0x61));
},
cancel() {
canceled = true;
},
});
const fetchMock = vi.fn(async (url: string | URL) => {
if (String(url).endsWith("/api/v1/models")) {
return jsonResponse({
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
});
}
if (String(url).endsWith("/api/v1/models/load")) {
return new Response(oversizedStream, {
status: 200,
headers: { "content-type": "application/json" },
});
}
throw new Error(`Unexpected fetch URL: ${String(url)}`);
});
vi.stubGlobal("fetch", asFetch(fetchMock));
const error = await ensureLmstudioModelLoaded({
baseUrl: "http://localhost:1234/v1",
modelKey: "qwen3-8b-instruct",
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(/JSON response exceeds \d+ bytes/);
expect(canceled).toBe(true);
expect(bytesEmitted).toBeLessThan(32 * 1024 * 1024);
});
it("bounds model load error bodies", async () => {