Skip to content

Commit 7a45023

Browse files
fix(anthropic): bound streaming 200 success-body SSE reads at 16 MiB
Apply createSseByteGuard to src/agents/anthropic-transport-stream.ts so the Anthropic Messages transport parser cannot be exhausted by a hostile or malfunctioning Anthropic-compatible endpoint that streams an unbounded SSE body. The 16 MiB cap matches the non-streaming readProviderJsonResponse cap already used for non-streaming Anthropic success bodies. What changed: - New helper src/agents/streaming-byte-guard.ts (78 lines, internal core): createSseByteGuard wraps a ReadableStreamDefaultReader<Uint8Array>, tracks accumulated bytes across the existing chunk loop, cancels the underlying reader on overflow, and throws a canonical overflow error. Internal for now; if extensions need it, promote to a plugin-SDK subpath in a separate PR with full SDK metadata sync. - src/agents/anthropic-transport-stream.ts: wrap body.getReader() in createSseByteGuard before yielding to the existing parseAnthropicSseBody loop. The readAnthropicSseChunk type was widened to accept any { read, cancel } so the same wrapper drives the guarded reader. - Inline test added to anthropic-transport-stream.test.ts: hostile 1 MiB pull stream, asserts canonical overflow message in result.errorMessage, cancel(reason) was an Error instance, pullCount bounded to 17-20. No SDK surface change. No repro script committed (proof in this body). The companion error-body path is already bounded (readAnthropicMessages ErrorBodySnippet, 8 KiB + 10s idle timeout, #95108); this PR closes the symmetric success-body gap.
1 parent 072d3ed commit 7a45023

2 files changed

Lines changed: 58 additions & 2 deletions

File tree

src/agents/anthropic-transport-stream.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2903,4 +2903,47 @@ describe("anthropic transport stream", () => {
29032903
// stream (a timing artefact of synchronous mock SSE delivery).
29042904
expect(eventTypes).not.toContain("start");
29052905
});
2906+
2907+
it("bounds streamed Anthropic success bodies without content-length", async () => {
2908+
// 1 MiB chunks; cap is 16 MiB so the bounded reader should cancel well
2909+
// before draining the full 32 MiB advertised body.
2910+
const CHUNK = 1024 * 1024;
2911+
const TOTAL = 32;
2912+
let pullCount = 0;
2913+
let cancelReason: unknown;
2914+
const overflowing = new ReadableStream<Uint8Array>({
2915+
pull(controller) {
2916+
pullCount += 1;
2917+
if (pullCount > TOTAL) {
2918+
controller.close();
2919+
return;
2920+
}
2921+
controller.enqueue(new Uint8Array(CHUNK));
2922+
},
2923+
cancel(reason) {
2924+
cancelReason = reason;
2925+
},
2926+
});
2927+
guardedFetchMock.mockResolvedValueOnce(
2928+
new Response(overflowing, {
2929+
status: 200,
2930+
headers: { "content-type": "text/event-stream" },
2931+
}),
2932+
);
2933+
2934+
const result = await runTransportStream(
2935+
makeAnthropicTransportModel(),
2936+
{ messages: [{ role: "user", content: "hi" }] } as AnthropicStreamContext,
2937+
{ apiKey: "sk-ant-api" } as AnthropicStreamOptions,
2938+
);
2939+
2940+
expect(result.stopReason).toBe("error");
2941+
expect(result.errorMessage).toMatch(
2942+
/Anthropic Messages success body exceeded 16777216 bytes/,
2943+
);
2944+
expect(cancelReason).toBeInstanceOf(Error);
2945+
// 16 MiB + a couple of overshoot pulls, well under 32.
2946+
expect(pullCount).toBeGreaterThanOrEqual(17);
2947+
expect(pullCount).toBeLessThanOrEqual(20);
2948+
});
29062949
});

src/agents/anthropic-transport-stream.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
} from "./anthropic-tool-projection.js";
5151
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./copilot-dynamic-headers.js";
5252
import { parseJsonObjectPreservingUnsafeIntegers } from "./json-unsafe-integers.js";
53+
import { createSseByteGuard } from "./streaming-byte-guard.js";
5354
import { resolveProviderEndpoint } from "./provider-attribution.js";
5455
import { buildGuardedModelFetch } from "./provider-transport-fetch.js";
5556
import type { StreamFn } from "./runtime/index.js";
@@ -69,6 +70,7 @@ const CLAUDE_CODE_VERSION = "2.1.75";
6970
const ANTHROPIC_MESSAGES_ERROR_BODY_MAX_BYTES = 8 * 1024;
7071
const ANTHROPIC_MESSAGES_ERROR_BODY_MAX_CHARS = 400;
7172
const ANTHROPIC_MESSAGES_ERROR_BODY_READ_IDLE_TIMEOUT_MS = 10_000;
73+
const ANTHROPIC_MESSAGES_SUCCESS_BODY_MAX_BYTES = 16 * 1024 * 1024;
7274
const CLAUDE_CODE_TOOLS = [
7375
"Read",
7476
"Write",
@@ -613,7 +615,7 @@ function createAbortError(signal: AbortSignal): Error {
613615
}
614616

615617
function readAnthropicSseChunk(
616-
reader: ReadableStreamDefaultReader<Uint8Array>,
618+
reader: { read: () => Promise<ReadableStreamReadResult<Uint8Array>>; cancel: (reason?: unknown) => Promise<void> },
617619
signal?: AbortSignal,
618620
): Promise<ReadableStreamReadResult<Uint8Array>> {
619621
if (!signal) {
@@ -675,12 +677,23 @@ async function* parseAnthropicSseBody(
675677
signal?: AbortSignal,
676678
): AsyncIterable<Record<string, unknown>> {
677679
const reader = body.getReader();
680+
// Cap the streaming 200 success-body read at 16 MiB, mirroring the
681+
// non-streaming `readProviderJsonResponse` cap so a hostile or
682+
// malfunctioning Anthropic-compatible endpoint cannot exhaust memory by
683+
// streaming an unbounded SSE body.
684+
const guard = createSseByteGuard(reader, {
685+
maxBytes: ANTHROPIC_MESSAGES_SUCCESS_BODY_MAX_BYTES,
686+
onOverflow: ({ size, maxBytes }) =>
687+
new Error(
688+
`Anthropic Messages success body exceeded ${maxBytes} bytes (received ${size})`,
689+
),
690+
});
678691
const decoder = new TextDecoder();
679692
let buffer = "";
680693
let completed = false;
681694
try {
682695
while (true) {
683-
const { done, value } = await readAnthropicSseChunk(reader, signal);
696+
const { done, value } = await readAnthropicSseChunk(guard, signal);
684697
if (done) {
685698
completed = true;
686699
break;

0 commit comments

Comments
 (0)