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 () => {