From 7844b0844568f8f71040008ef596bc715064657b Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Wed, 24 Jun 2026 21:03:02 +0800 Subject: [PATCH] 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 --- extensions/lmstudio/src/models.fetch.ts | 14 +++--- extensions/lmstudio/src/models.test.ts | 64 ++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/extensions/lmstudio/src/models.fetch.ts b/extensions/lmstudio/src/models.fetch.ts index 2b53c959e938..2cd2dbdd9927 100644 --- a/extensions/lmstudio/src/models.fetch.ts +++ b/extensions/lmstudio/src/models.fetch.ts @@ -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( + 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}`); } diff --git a/extensions/lmstudio/src/models.test.ts b/extensions/lmstudio/src/models.test.ts index 91a45a4f5333..b5ec7b1f9a0f 100644 --- a/extensions/lmstudio/src/models.test.ts +++ b/extensions/lmstudio/src/models.test.ts @@ -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({ + 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 () => {