Skip to content

Commit bbc9a7d

Browse files
committed
fix(e2e): bound OpenWebUI probe response bodies
1 parent d47eee4 commit bbc9a7d

2 files changed

Lines changed: 130 additions & 6 deletions

File tree

scripts/e2e/openwebui-probe.mjs

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const controlTimeoutMs = readPositiveInt(
1313
Math.min(fetchTimeoutMs, 30000),
1414
);
1515
const chatTimeoutMs = readPositiveInt("OPENWEBUI_CHAT_TIMEOUT_MS", fetchTimeoutMs);
16+
const responseBodyMaxBytes = readPositiveInt("OPENWEBUI_RESPONSE_BODY_MAX_BYTES", 1024 * 1024);
1617
const smokeMode =
1718
process.env.OPENWEBUI_SMOKE_MODE ?? process.env.OPENCLAW_OPENWEBUI_SMOKE_MODE ?? "chat";
1819

@@ -68,6 +69,12 @@ function createTimeoutError(label, timeoutMs) {
6869
return error;
6970
}
7071

72+
function createBodyTooLargeError(label, byteLimit) {
73+
const error = new Error(`${label} response body exceeded ${byteLimit} bytes`);
74+
error.code = "ETOOBIG";
75+
return error;
76+
}
77+
7178
async function withRequestTimeout(label, timeoutMs, run) {
7279
const controller = new AbortController();
7380
const timeoutError = createTimeoutError(label, timeoutMs);
@@ -87,6 +94,51 @@ async function withRequestTimeout(label, timeoutMs, run) {
8794
}
8895
}
8996

97+
async function readBoundedResponseText(response, label, byteLimit = responseBodyMaxBytes) {
98+
const contentLength = response.headers.get("content-length");
99+
if (contentLength) {
100+
const parsedLength = Number(contentLength);
101+
if (Number.isSafeInteger(parsedLength) && parsedLength > byteLimit) {
102+
await response.body?.cancel().catch(() => {});
103+
throw createBodyTooLargeError(label, byteLimit);
104+
}
105+
}
106+
if (!response.body) {
107+
return "";
108+
}
109+
110+
const reader = response.body.getReader();
111+
const decoder = new TextDecoder();
112+
let byteCount = 0;
113+
let text = "";
114+
try {
115+
while (true) {
116+
const { done, value } = await reader.read();
117+
if (done) {
118+
return text + decoder.decode();
119+
}
120+
byteCount += value.byteLength;
121+
if (byteCount > byteLimit) {
122+
await reader.cancel().catch(() => {});
123+
throw createBodyTooLargeError(label, byteLimit);
124+
}
125+
text += decoder.decode(value, { stream: true });
126+
}
127+
} finally {
128+
reader.releaseLock();
129+
}
130+
}
131+
132+
async function readBoundedResponseJson(response, label) {
133+
const body = await readBoundedResponseText(response, label);
134+
try {
135+
return JSON.parse(body);
136+
} catch (error) {
137+
const message = error instanceof Error ? error.message : String(error);
138+
throw new Error(`${label} returned invalid JSON: ${message}`, { cause: error });
139+
}
140+
}
141+
90142
function getCookieHeader(res) {
91143
const raw = res.headers.get("set-cookie");
92144
if (!raw) {
@@ -125,12 +177,12 @@ async function fetchSignin() {
125177
signal,
126178
});
127179
if (!response.ok) {
128-
const body = await response.text();
180+
const body = await readBoundedResponseText(response, "Open WebUI signin");
129181
throw new Error(`signin failed: HTTP ${response.status} ${body}`);
130182
}
131183
return {
132184
cookie: getCookieHeader(response),
133-
json: await response.json(),
185+
json: await readBoundedResponseJson(response, "Open WebUI signin"),
134186
};
135187
});
136188
}
@@ -145,11 +197,14 @@ async function fetchModels(authHeaders, attempt) {
145197
return {
146198
ok: false,
147199
status: response.status,
148-
text: await response.text(),
200+
text: await readBoundedResponseText(
201+
response,
202+
`Open WebUI models attempt ${attempt}`,
203+
),
149204
};
150205
}
151206
return {
152-
json: await response.json(),
207+
json: await readBoundedResponseJson(response, `Open WebUI models attempt ${attempt}`),
153208
ok: true,
154209
};
155210
},
@@ -171,11 +226,12 @@ async function fetchChatCompletion(authHeaders, targetModel) {
171226
signal,
172227
});
173228
if (!response.ok) {
229+
const body = await readBoundedResponseText(response, "Open WebUI chat completion");
174230
throw new Error(
175-
`/api/chat/completions failed: HTTP ${response.status} ${await response.text()}`,
231+
`/api/chat/completions failed: HTTP ${response.status} ${body}`,
176232
);
177233
}
178-
return await response.json();
234+
return await readBoundedResponseJson(response, "Open WebUI chat completion");
179235
});
180236
}
181237

test/scripts/openwebui-probe.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,18 @@ describe("scripts/e2e/openwebui-probe.mjs", () => {
102102
expect(result.stderr).toContain("OPENWEBUI_MODEL_ATTEMPTS must be a positive integer; got: 0");
103103
});
104104

105+
it("rejects loose response body cap env values instead of parsing prefixes", async () => {
106+
const result = await runProbe("http://127.0.0.1:9", {
107+
OPENWEBUI_RESPONSE_BODY_MAX_BYTES: "1mb",
108+
});
109+
110+
expect(result.error).toBeUndefined();
111+
expect(result.status).not.toBe(0);
112+
expect(result.stderr).toContain(
113+
"OPENWEBUI_RESPONSE_BODY_MAX_BYTES must be a positive integer; got: 1mb",
114+
);
115+
});
116+
105117
it("uses a short control-plane timeout for stalled sign-in requests", async () => {
106118
const sockets = new Set<Socket>();
107119
const server = createTcpServer((socket) => {
@@ -163,6 +175,62 @@ describe("scripts/e2e/openwebui-probe.mjs", () => {
163175
}
164176
});
165177

178+
it("bounds sign-in error response bodies", async () => {
179+
const server = createServer((request, response) => {
180+
if (request.url === "/api/v1/auths/signin") {
181+
response.writeHead(500, { "content-type": "text/plain" });
182+
response.end("x".repeat(64));
183+
return;
184+
}
185+
response.writeHead(404).end();
186+
});
187+
const baseUrl = await listen(server);
188+
try {
189+
const result = await runProbe(baseUrl, {
190+
OPENWEBUI_RESPONSE_BODY_MAX_BYTES: "16",
191+
});
192+
193+
expect(result.error).toBeUndefined();
194+
expect(result.status).not.toBe(0);
195+
expect(result.stderr).toContain("Open WebUI signin response body exceeded 16 bytes");
196+
expect(result.stderr).not.toContain("x".repeat(64));
197+
} finally {
198+
server.close();
199+
}
200+
});
201+
202+
it("bounds model-list error response bodies", async () => {
203+
const server = createServer((request, response) => {
204+
if (request.url === "/api/v1/auths/signin") {
205+
response.writeHead(200, {
206+
"content-type": "application/json",
207+
"set-cookie": "openwebui-session=test; Path=/",
208+
});
209+
response.end(JSON.stringify({ token: "test-token" }));
210+
return;
211+
}
212+
if (request.url === "/api/models") {
213+
response.writeHead(502, { "content-type": "text/plain" });
214+
response.end("y".repeat(96));
215+
return;
216+
}
217+
response.writeHead(404).end();
218+
});
219+
const baseUrl = await listen(server);
220+
try {
221+
const result = await runProbe(baseUrl, {
222+
OPENWEBUI_RESPONSE_BODY_MAX_BYTES: "32",
223+
});
224+
225+
expect(result.error).toBeUndefined();
226+
expect(result.status).not.toBe(0);
227+
expect(result.stderr).toContain("Open WebUI models attempt 1 response body exceeded 32 bytes");
228+
expect(result.stderr).not.toContain("y".repeat(96));
229+
} finally {
230+
server.close();
231+
}
232+
});
233+
166234
it("does not sleep after the final model-list attempt", async () => {
167235
const server = createServer((request, response) => {
168236
if (request.url === "/api/v1/auths/signin") {

0 commit comments

Comments
 (0)