fix(lmstudio): redact reflected model-load credentials (#128561)

* test(lmstudio): add reflected credential redaction regression

* fix(lmstudio): redact exact outbound credentials in model load error body

* fix(lmstudio): suppress truncated model load error bodies before redaction

* fix(lmstudio): restore redactToolPayloadText import after rebase

* test(lmstudio): add real HTTP-server credential redaction proof

* fix(lmstudio): harden reflected model-load credentials

Co-authored-by: xialonglee <li.xialong@xydigit.com>

* test(lmstudio): isolate reflected model-load transport proof

---------

Co-authored-by: xialonglee <li.xialong@xydigit.com>
This commit is contained in:
Peter Steinberger
2026-08-24 00:18:43 -07:00
committed by GitHub
parent 4dc083dc29
commit 62aca9a785
3 changed files with 190 additions and 16 deletions
+36 -10
View File
@@ -1,13 +1,13 @@
// Lmstudio plugin module implements models.fetch behavior.
import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core";
import { createSubsystemLogger, redactToolPayloadText } 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";
import { SELF_HOSTED_DEFAULT_COST } from "openclaw/plugin-sdk/provider-setup";
import { readResponseTextPrefix } from "openclaw/plugin-sdk/response-limit-runtime";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { asPositiveSafeInteger } from "openclaw/plugin-sdk/string-coerce-runtime";
import { LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH } from "./defaults.js";
@@ -24,6 +24,24 @@ import { buildLmstudioAuthHeaders } from "./runtime.js";
const log = createSubsystemLogger("extensions/lmstudio/models");
const LMSTUDIO_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
function redactLmstudioLoadError(value: string, headers: Record<string, string> | undefined) {
const credentials = Object.entries(headers ?? {})
.filter(([name]) => name.toLowerCase() !== "content-type")
.flatMap(([name, header]) => {
const normalized = header.trim();
if (!normalized) {
return [];
}
return name.toLowerCase() === "authorization"
? [normalized, normalized.replace(/^\S+\s+/u, "")]
: [normalized];
})
.toSorted((left, right) => right.length - left.length);
return redactToolPayloadText(
credentials.reduce((redacted, credential) => redacted.replaceAll(credential, "***"), value),
);
}
type LmstudioLoadResponse = {
status?: string;
};
@@ -275,15 +293,16 @@ export async function ensureLmstudioModelLoaded(params: {
}
try {
const requestHeaders = buildLmstudioAuthHeaders({
apiKey: params.apiKey,
headers: params.headers,
json: true,
});
const { response, release } = await fetchLmstudioEndpoint({
url: `${baseUrl}/api/v1/models/load`,
init: {
method: "POST",
headers: buildLmstudioAuthHeaders({
apiKey: params.apiKey,
headers: params.headers,
json: true,
}),
headers: requestHeaders,
body: JSON.stringify({
model: canonicalModelKey,
// Ask LM Studio to load with our default target, capped to the model's own limit.
@@ -297,9 +316,15 @@ export async function ensureLmstudioModelLoaded(params: {
});
try {
if (!response.ok) {
const body = await readResponseTextLimited(response, LMSTUDIO_ERROR_BODY_LIMIT_BYTES);
const bodyRead = await readResponseTextPrefix(response, LMSTUDIO_ERROR_BODY_LIMIT_BYTES, {
chunkTimeoutMs: 10_000,
});
// A truncated credential cannot be identified safely; drop the entire diagnostic.
const detail = bodyRead.truncated
? ""
: redactLmstudioLoadError(bodyRead.text, requestHeaders);
throw new Error(
`LM Studio model load failed (${response.status})${body ? `: ${body}` : ""}`,
`LM Studio model load failed (${response.status})${detail ? `: ${detail}` : ""}`,
);
}
// Read the success body through the shared byte-capped reader so a misbehaving
@@ -310,7 +335,8 @@ export async function ensureLmstudioModelLoaded(params: {
"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}`);
const status = redactLmstudioLoadError(payload.status, requestHeaders);
throw new Error(`LM Studio model load returned unexpected status: ${status}`);
}
} finally {
await release();
@@ -0,0 +1,58 @@
import { createServer } from "node:http";
import { describe, expect, it } from "vitest";
import { ensureLmstudioModelLoaded } from "./models.fetch.js";
describe("LM Studio model-load error transport", () => {
it("redacts actual outbound credentials reflected by a model-load HTTP server", async () => {
let responseStatus = 502;
const server = createServer((request, response) => {
if (request.method === "GET") {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(
JSON.stringify({
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
}),
);
return;
}
const authorization = request.headers.authorization;
const proxyAuthorization = request.headers["x-proxy-auth"];
if (typeof authorization !== "string" || typeof proxyAuthorization !== "string") {
response.writeHead(500);
response.end("missing expected authentication headers");
return;
}
const reflected = `upstream rejected ${authorization}; proxy ${proxyAuthorization}`;
response.writeHead(responseStatus, { "Content-Type": "application/json" });
response.end(responseStatus === 200 ? JSON.stringify({ status: reflected }) : reflected);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
try {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("LM Studio test server did not bind to a local port");
}
for (const [status, expected] of [
[502, "LM Studio model load failed (502): upstream rejected ***; proxy ***"],
[200, "LM Studio model load returned unexpected status: upstream rejected ***; proxy ***"],
] as const) {
responseStatus = status;
await expect(
ensureLmstudioModelLoaded({
baseUrl: `http://127.0.0.1:${address.port}`,
modelKey: "qwen3-8b-instruct",
apiKey: "sk-test",
headers: { "X-Proxy-Auth": "opaque-short" },
}),
).rejects.toThrow(expected);
}
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
});
+96 -6
View File
@@ -895,8 +895,9 @@ describe("lmstudio-models", () => {
expect(bytesEmitted).toBeLessThan(32 * 1024 * 1024);
});
it("bounds model load error bodies", async () => {
const body = `${"lmstudio load unavailable ".repeat(512)}tail`;
it("suppresses truncated model load error bodies", async () => {
const credential = "split-credential-xyz";
const body = `${"x".repeat(8 * 1024 - 2)}${credential}${"y".repeat(1000)}`;
const tracked = cancelTrackedResponse(body, { status: 503 });
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetchMock = vi.fn(async (url: string | URL) => {
@@ -914,17 +915,106 @@ describe("lmstudio-models", () => {
const error = await ensureLmstudioModelLoaded({
baseUrl: "http://localhost:1234/v1",
apiKey: credential,
modelKey: "qwen3-8b-instruct",
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(
/LM Studio model load failed \(503\): lmstudio load unavailable/,
);
expect((error as Error).message).not.toContain("tail");
expect((error as Error).message).toBe("LM Studio model load failed (503)");
expect((error as Error).message).not.toContain(credential);
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it.each<{
name: string;
params: Pick<Parameters<typeof ensureLmstudioModelLoaded>[0], "apiKey" | "headers">;
body: string;
status: number;
expected: string;
}>([
{
name: "redacts trimmed short API keys without losing safe diagnostics",
params: { apiKey: " sk-test " },
body: "upstream rejected Bearer sk-test; GPU out of memory",
status: 502,
expected: "LM Studio model load failed (502): upstream rejected ***; GPU out of memory",
},
{
name: "redacts bare credentials from lowercase header-only bearer auth",
params: { headers: { authorization: "bearer opaque-short" } },
body: "upstream rejected opaque-short",
status: 502,
expected: "LM Studio model load failed (502): upstream rejected ***",
},
{
name: "redacts opaque custom authentication header values",
params: { headers: { "X-Proxy-Auth": "proxy-p7" } },
body: "proxy rejected proxy-p7",
status: 502,
expected: "LM Studio model load failed (502): proxy rejected ***",
},
{
name: "redacts overlapping credentials longest first",
params: { apiKey: "abc", headers: { "X-Proxy-Auth": "abcdef" } },
body: "proxy rejected abcdef and abc",
status: 502,
expected: "LM Studio model load failed (502): proxy rejected *** and ***",
},
{
name: "redacts only the authorization value actually sent",
params: { apiKey: "fresh", headers: { Authorization: "Bearer replaced-old" } },
body: "stale replaced-old; active fresh",
status: 502,
expected: "LM Studio model load failed (502): stale replaced-old; active ***",
},
{
name: "preserves synthetic markers and the generated content type",
params: { apiKey: "lmstudio-local" },
body: "lmstudio-local rejected application/json; GPU out of memory",
status: 502,
expected:
"LM Studio model load failed (502): lmstudio-local rejected application/json; GPU out of memory",
},
{
name: "redacts unrelated recognizable provider credentials",
params: {},
body: "upstream Authorization: Bearer sk-test",
status: 502,
expected: "LM Studio model load failed (502): upstream Authorization: Bearer ***",
},
{
name: "redacts reflected credentials in successful unexpected statuses",
params: { headers: { authorization: "bearer opaque-short" } },
body: "backend rejected opaque-short",
status: 200,
expected: "LM Studio model load returned unexpected status: backend rejected ***",
},
])("$name", async ({ params, body, status, expected }) => {
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 status === 200 ? jsonResponse({ status: body }) : new Response(body, { status });
}
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",
...params,
}).catch((caught: unknown) => caught);
expect(error).toMatchObject({
message: expected,
resolvedModelKey: "qwen3-8b-instruct",
});
});
it("loads model with clamped context length and merged headers", async () => {
const fetchMock = createModelLoadFetchMock({ maxContextLength: 32768 });
vi.stubGlobal("fetch", asFetch(fetchMock));