Skip to content

Commit 7844b08

Browse files
authored
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
1 parent ae9474b commit 7844b08

2 files changed

Lines changed: 71 additions & 7 deletions

File tree

extensions/lmstudio/src/models.fetch.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core";
33
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
44
import {
55
readProviderJsonArrayFieldResponse,
6+
readProviderJsonResponse,
67
readResponseTextLimited,
78
} from "openclaw/plugin-sdk/provider-http";
89
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
@@ -285,12 +286,13 @@ export async function ensureLmstudioModelLoaded(params: {
285286
`LM Studio model load failed (${response.status})${body ? `: ${body}` : ""}`,
286287
);
287288
}
288-
let payload: LmstudioLoadResponse;
289-
try {
290-
payload = (await response.json()) as LmstudioLoadResponse;
291-
} catch (cause) {
292-
throw new Error("LM Studio model load returned malformed JSON", { cause });
293-
}
289+
// Read the success body through the shared byte-capped reader so a misbehaving
290+
// or compromised LM Studio server cannot stream an unbounded JSON payload into
291+
// memory before we parse it. Malformed JSON is wrapped with our own label.
292+
const payload = await readProviderJsonResponse<LmstudioLoadResponse>(
293+
response,
294+
"LM Studio model load",
295+
);
294296
if (typeof payload.status === "string" && payload.status.toLowerCase() !== "loaded") {
295297
throw new Error(`LM Studio model load returned unexpected status: ${payload.status}`);
296298
}

extensions/lmstudio/src/models.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,21 @@ describe("lmstudio-models", () => {
5959
}
6060
return JSON.parse(init.body) as unknown;
6161
};
62+
// The model fetch/load helpers now read bodies through the shared byte-capped
63+
// reader, so success-path mocks must be real Response objects with a body
64+
// stream rather than bare `{ ok, json }` placeholders.
65+
const jsonResponse = (payload: unknown, init?: ResponseInit): Response =>
66+
new Response(JSON.stringify(payload), {
67+
status: 200,
68+
headers: { "content-type": "application/json" },
69+
...init,
70+
});
71+
const malformedJsonResponse = (init?: ResponseInit): Response =>
72+
new Response("{ this is not valid json", {
73+
status: 200,
74+
headers: { "content-type": "application/json" },
75+
...init,
76+
});
6277
const cancelTrackedResponse = (
6378
text: string,
6479
init: ResponseInit,
@@ -89,6 +104,7 @@ describe("lmstudio-models", () => {
89104
}) =>
90105
vi.fn(async (url: string | URL, _init?: RequestInit) => {
91106
const key = params?.key ?? "qwen3-8b-instruct";
107+
void init;
92108
if (String(url).endsWith("/api/v1/models")) {
93109
return jsonResponse({
94110
models: [
@@ -582,7 +598,53 @@ describe("lmstudio-models", () => {
582598
baseUrl: "http://localhost:1234/v1",
583599
modelKey: "qwen3-8b-instruct",
584600
}),
585-
).rejects.toThrow("LM Studio model load returned malformed JSON");
601+
).rejects.toThrow("LM Studio model load: malformed JSON response");
602+
});
603+
604+
it("bounds oversized model load success bodies", async () => {
605+
// A misbehaving server may stream an unbounded success JSON body; the load
606+
// path must stop reading at the byte cap instead of buffering it all.
607+
let canceled = false;
608+
let bytesEmitted = 0;
609+
const oversizedStream = new ReadableStream<Uint8Array>({
610+
pull(controller) {
611+
// Far exceeds the 16 MiB provider JSON cap if read to completion.
612+
if (bytesEmitted >= 32 * 1024 * 1024) {
613+
controller.close();
614+
return;
615+
}
616+
bytesEmitted += 64 * 1024;
617+
controller.enqueue(new Uint8Array(64 * 1024).fill(0x61));
618+
},
619+
cancel() {
620+
canceled = true;
621+
},
622+
});
623+
const fetchMock = vi.fn(async (url: string | URL) => {
624+
if (String(url).endsWith("/api/v1/models")) {
625+
return jsonResponse({
626+
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
627+
});
628+
}
629+
if (String(url).endsWith("/api/v1/models/load")) {
630+
return new Response(oversizedStream, {
631+
status: 200,
632+
headers: { "content-type": "application/json" },
633+
});
634+
}
635+
throw new Error(`Unexpected fetch URL: ${String(url)}`);
636+
});
637+
vi.stubGlobal("fetch", asFetch(fetchMock));
638+
639+
const error = await ensureLmstudioModelLoaded({
640+
baseUrl: "http://localhost:1234/v1",
641+
modelKey: "qwen3-8b-instruct",
642+
}).catch((caught: unknown) => caught);
643+
644+
expect(error).toBeInstanceOf(Error);
645+
expect((error as Error).message).toMatch(/JSON response exceeds \d+ bytes/);
646+
expect(canceled).toBe(true);
647+
expect(bytesEmitted).toBeLessThan(32 * 1024 * 1024);
586648
});
587649

588650
it("bounds model load error bodies", async () => {

0 commit comments

Comments
 (0)