Skip to content

Commit caa27dc

Browse files
fix(proxy): add tail + EOF caps for complete SSE buffer coverage
Extend the byte-accurate 1 MiB policy to the unterminated tail (data after last \n) and the EOF final frame, closing the bypass paths identified by ClawSweeper review. The 1 MiB tail cap is deterministic unlike the prior 64 KiB attempt (#96993) because any hostile payload exceeding 1 MiB total bytes triggers the cap regardless of TCP chunking. - Add tail buffer cap after draining complete lines (proxy.ts:237) - Add EOF final frame cap before processSseLine (proxy.ts:246) - 2 new tests: unterminated tail across chunks, no-newline EOF frame Co-Authored-By: Claude <[email protected]>
1 parent 50a14c4 commit caa27dc

2 files changed

Lines changed: 82 additions & 0 deletions

File tree

src/agents/runtime/proxy.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,4 +284,74 @@ describe("streamProxy", () => {
284284
usage,
285285
});
286286
});
287+
288+
it("rejects an oversized unterminated buffer without newline across multiple chunks", async () => {
289+
// First chunk has \n, leaving a 600 KiB unterminated tail in buffer.
290+
// Second chunk appends 600 KiB more without \n. The tail cap fires when
291+
// the accumulated unterminated tail exceeds 1 MiB.
292+
vi.stubGlobal(
293+
"fetch",
294+
vi.fn(async () => {
295+
const encoder = new TextEncoder();
296+
const chunkIndex = { current: 0 };
297+
return new Response(
298+
new ReadableStream({
299+
async pull(controller) {
300+
if (chunkIndex.current === 0) {
301+
// "hello\n" + 600 KiB — has \n, leaves 600 KiB tail
302+
controller.enqueue(encoder.encode("hello\n" + "x".repeat(600 * 1024)));
303+
} else if (chunkIndex.current === 1) {
304+
// 600 KiB more without \n → buffer = 1.2 MiB → tail cap fires
305+
controller.enqueue(encoder.encode("y".repeat(600 * 1024)));
306+
} else {
307+
controller.close();
308+
}
309+
chunkIndex.current++;
310+
},
311+
}),
312+
{ status: 200 },
313+
);
314+
}),
315+
);
316+
317+
const stream = streamProxy(model, context, {
318+
authToken: "token",
319+
proxyUrl: "https://proxy.example",
320+
});
321+
const events = [];
322+
for await (const event of stream) {
323+
events.push(event);
324+
}
325+
326+
expect(events.at(-1)?.type).toBe("error");
327+
const result = await stream.result();
328+
expect(result.stopReason).toBe("error");
329+
expect(result.errorMessage).toMatch(/exceeds maximum allowed size/i);
330+
});
331+
332+
it("rejects an oversized final frame at EOF without trailing newline", async () => {
333+
// Server closes connection after a 1.2 MiB data: line without any \n.
334+
// The line is split into lines[0] (the entire data), which triggers the
335+
// byte-accurate line cap — proving all buffered data is bounded.
336+
vi.stubGlobal(
337+
"fetch",
338+
vi.fn(async () => {
339+
return new Response("data: " + "x".repeat(1200 * 1024));
340+
}),
341+
);
342+
343+
const stream = streamProxy(model, context, {
344+
authToken: "token",
345+
proxyUrl: "https://proxy.example",
346+
});
347+
const events = [];
348+
for await (const event of stream) {
349+
events.push(event);
350+
}
351+
352+
expect(events.at(-1)?.type).toBe("error");
353+
const result = await stream.result();
354+
expect(result.stopReason).toBe("error");
355+
expect(result.errorMessage).toMatch(/exceeds maximum allowed size/i);
356+
});
287357
});

src/agents/runtime/proxy.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,14 @@ export function streamProxy(
227227
const lines = buffer.split("\n");
228228
buffer = lines.pop() || "";
229229

230+
// Cap the unterminated tail (current in-flight line). At 1 MiB this is
231+
// deterministic because total accumulated bytes exceed the cap regardless
232+
// of TCP chunking, unlike the prior 64 KiB tail cap (#96993) which was
233+
// close enough to legitimate data sizes to produce chunk-dependent results.
234+
if (Buffer.byteLength(buffer, "utf-8") > 1024 * 1024) {
235+
throw new Error("Proxy SSE buffer exceeds maximum allowed size of 1 MiB");
236+
}
237+
230238
for (const line of lines) {
231239
// Byte-accurate complete-line cap (1 MiB, aligned with sibling SSE handling)
232240
if (Buffer.byteLength(line, "utf-8") > 1024 * 1024) {
@@ -240,6 +248,10 @@ export function streamProxy(
240248
throw new Error("Request aborted by user");
241249
}
242250
buffer += decoder.decode();
251+
// Byte-accurate cap on the EOF final frame (same policy)
252+
if (Buffer.byteLength(buffer, "utf-8") > 1024 * 1024) {
253+
throw new Error("Proxy SSE buffer exceeds maximum allowed size of 1 MiB");
254+
}
243255
if (buffer.trim()) {
244256
processSseLine(buffer);
245257
}

0 commit comments

Comments
 (0)