Skip to content

Commit 90c3b96

Browse files
committed
fix(lmstudio): bound model load success response body to prevent OOM
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 2824c02 commit 90c3b96

2 files changed

Lines changed: 112 additions & 83 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: 104 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,21 @@ describe("lmstudio-models", () => {
4444
}
4545
return JSON.parse(init.body) as unknown;
4646
};
47+
// The model fetch/load helpers now read bodies through the shared byte-capped
48+
// reader, so success-path mocks must be real Response objects with a body
49+
// stream rather than bare `{ ok, json }` placeholders.
50+
const jsonResponse = (payload: unknown, init?: ResponseInit): Response =>
51+
new Response(JSON.stringify(payload), {
52+
status: 200,
53+
headers: { "content-type": "application/json" },
54+
...init,
55+
});
56+
const malformedJsonResponse = (init?: ResponseInit): Response =>
57+
new Response("{ this is not valid json", {
58+
status: 200,
59+
headers: { "content-type": "application/json" },
60+
...init,
61+
});
4762
const cancelTrackedResponse = (
4863
text: string,
4964
init: ResponseInit,
@@ -74,31 +89,25 @@ describe("lmstudio-models", () => {
7489
}) =>
7590
vi.fn(async (url: string | URL, init?: RequestInit) => {
7691
const key = params?.key ?? "qwen3-8b-instruct";
92+
void init;
7793
if (String(url).endsWith("/api/v1/models")) {
78-
return {
79-
ok: true,
80-
json: async () => ({
81-
models: [
82-
{
83-
type: "llm",
84-
key,
85-
max_context_length: params?.maxContextLength,
86-
variants: params?.variants,
87-
selected_variant: params?.selectedVariant,
88-
loaded_instances: params?.loadedContextLength
89-
? [{ id: "inst-1", config: { context_length: params.loadedContextLength } }]
90-
: [],
91-
},
92-
],
93-
}),
94-
};
94+
return jsonResponse({
95+
models: [
96+
{
97+
type: "llm",
98+
key,
99+
max_context_length: params?.maxContextLength,
100+
variants: params?.variants,
101+
selected_variant: params?.selectedVariant,
102+
loaded_instances: params?.loadedContextLength
103+
? [{ id: "inst-1", config: { context_length: params.loadedContextLength } }]
104+
: [],
105+
},
106+
],
107+
});
95108
}
96109
if (String(url).endsWith("/api/v1/models/load")) {
97-
return {
98-
ok: true,
99-
json: async () => ({ status: "loaded" }),
100-
requestInit: init,
101-
};
110+
return jsonResponse({ status: "loaded" });
102111
}
103112
throw new Error(`Unexpected fetch URL: ${String(url)}`);
104113
});
@@ -296,9 +305,8 @@ describe("lmstudio-models", () => {
296305
});
297306

298307
it("discovers llm models and maps metadata", async () => {
299-
const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => ({
300-
ok: true,
301-
json: async () => ({
308+
const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) =>
309+
jsonResponse({
302310
models: [
303311
{
304312
type: "llm",
@@ -330,7 +338,7 @@ describe("lmstudio-models", () => {
330338
},
331339
],
332340
}),
333-
}));
341+
);
334342

335343
const models = await discoverLmstudioModels({
336344
baseUrl: "http://localhost:1234/v1",
@@ -386,13 +394,7 @@ describe("lmstudio-models", () => {
386394
});
387395

388396
it("reports malformed model list JSON with an owned error", async () => {
389-
const fetchMock = vi.fn(async () => ({
390-
ok: true,
391-
status: 200,
392-
json: async () => {
393-
throw new SyntaxError("bad json");
394-
},
395-
}));
397+
const fetchMock = vi.fn(async () => malformedJsonResponse());
396398

397399
const result = await fetchLmstudioModels({
398400
baseUrl: "http://localhost:1234/v1",
@@ -405,11 +407,7 @@ describe("lmstudio-models", () => {
405407

406408
it("reports wrong-shaped model list payloads with owned errors", async () => {
407409
for (const payload of [[], { models: {} }, { models: [null] }]) {
408-
const fetchMock = vi.fn(async () => ({
409-
ok: true,
410-
status: 200,
411-
json: async () => payload,
412-
}));
410+
const fetchMock = vi.fn(async () => jsonResponse(payload));
413411

414412
const result = await fetchLmstudioModels({
415413
baseUrl: "http://localhost:1234/v1",
@@ -424,12 +422,9 @@ describe("lmstudio-models", () => {
424422
it("caps oversized direct fetch timeouts before discovering models", async () => {
425423
const timeoutController = new AbortController();
426424
const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal);
427-
const fetchMock = vi.fn(async (_url: string | URL, init?: RequestInit) => ({
428-
ok: true,
429-
status: 200,
430-
requestInit: init,
431-
json: async () => ({ models: [] }),
432-
}));
425+
const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) =>
426+
jsonResponse({ models: [] }),
427+
);
433428

434429
const result = await fetchLmstudioModels({
435430
baseUrl: "http://localhost:1234/v1",
@@ -521,20 +516,17 @@ describe("lmstudio-models", () => {
521516
const variantKey = `${canonicalKey}@q4_k_m`;
522517
const fetchMock = vi.fn(async (url: string | URL) => {
523518
if (String(url).endsWith("/api/v1/models")) {
524-
return {
525-
ok: true,
526-
json: async () => ({
527-
models: [
528-
{
529-
type: "llm",
530-
key: canonicalKey,
531-
variants: [variantKey],
532-
selected_variant: variantKey,
533-
loaded_instances: [],
534-
},
535-
],
536-
}),
537-
};
519+
return jsonResponse({
520+
models: [
521+
{
522+
type: "llm",
523+
key: canonicalKey,
524+
variants: [variantKey],
525+
selected_variant: variantKey,
526+
loaded_instances: [],
527+
},
528+
],
529+
});
538530
}
539531
if (String(url).endsWith("/api/v1/models/load")) {
540532
return new Response("load failed", { status: 503 });
@@ -575,20 +567,12 @@ describe("lmstudio-models", () => {
575567
it("reports malformed model load JSON with an owned error", async () => {
576568
const fetchMock = vi.fn(async (url: string | URL) => {
577569
if (String(url).endsWith("/api/v1/models")) {
578-
return {
579-
ok: true,
580-
json: async () => ({
581-
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
582-
}),
583-
};
570+
return jsonResponse({
571+
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
572+
});
584573
}
585574
if (String(url).endsWith("/api/v1/models/load")) {
586-
return {
587-
ok: true,
588-
json: async () => {
589-
throw new SyntaxError("bad json");
590-
},
591-
};
575+
return malformedJsonResponse();
592576
}
593577
throw new Error(`Unexpected fetch URL: ${String(url)}`);
594578
});
@@ -599,7 +583,53 @@ describe("lmstudio-models", () => {
599583
baseUrl: "http://localhost:1234/v1",
600584
modelKey: "qwen3-8b-instruct",
601585
}),
602-
).rejects.toThrow("LM Studio model load returned malformed JSON");
586+
).rejects.toThrow("LM Studio model load: malformed JSON response");
587+
});
588+
589+
it("bounds oversized model load success bodies", async () => {
590+
// A misbehaving server may stream an unbounded success JSON body; the load
591+
// path must stop reading at the byte cap instead of buffering it all.
592+
let canceled = false;
593+
let bytesEmitted = 0;
594+
const oversizedStream = new ReadableStream<Uint8Array>({
595+
pull(controller) {
596+
// Far exceeds the 16 MiB provider JSON cap if read to completion.
597+
if (bytesEmitted >= 32 * 1024 * 1024) {
598+
controller.close();
599+
return;
600+
}
601+
bytesEmitted += 64 * 1024;
602+
controller.enqueue(new Uint8Array(64 * 1024).fill(0x61));
603+
},
604+
cancel() {
605+
canceled = true;
606+
},
607+
});
608+
const fetchMock = vi.fn(async (url: string | URL) => {
609+
if (String(url).endsWith("/api/v1/models")) {
610+
return jsonResponse({
611+
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
612+
});
613+
}
614+
if (String(url).endsWith("/api/v1/models/load")) {
615+
return new Response(oversizedStream, {
616+
status: 200,
617+
headers: { "content-type": "application/json" },
618+
});
619+
}
620+
throw new Error(`Unexpected fetch URL: ${String(url)}`);
621+
});
622+
vi.stubGlobal("fetch", asFetch(fetchMock));
623+
624+
const error = await ensureLmstudioModelLoaded({
625+
baseUrl: "http://localhost:1234/v1",
626+
modelKey: "qwen3-8b-instruct",
627+
}).catch((caught: unknown) => caught);
628+
629+
expect(error).toBeInstanceOf(Error);
630+
expect((error as Error).message).toMatch(/JSON response exceeds \d+ bytes/);
631+
expect(canceled).toBe(true);
632+
expect(bytesEmitted).toBeLessThan(32 * 1024 * 1024);
603633
});
604634

605635
it("bounds model load error bodies", async () => {
@@ -608,12 +638,9 @@ describe("lmstudio-models", () => {
608638
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
609639
const fetchMock = vi.fn(async (url: string | URL) => {
610640
if (String(url).endsWith("/api/v1/models")) {
611-
return {
612-
ok: true,
613-
json: async () => ({
614-
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
615-
}),
616-
};
641+
return jsonResponse({
642+
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
643+
});
617644
}
618645
if (String(url).endsWith("/api/v1/models/load")) {
619646
return tracked.response;

0 commit comments

Comments
 (0)