Skip to content

Commit e6e8ce9

Browse files
authored
fix(client): bound SSE event size to prevent client-side memory exhaustion (#582)
# Description `parseSseStream` (shared by the JSON-RPC and REST client transports) accumulates two buffers with **no size bound**: - the line `buffer` — `buffer += value` on every chunk - an event's joined `eventData` — consecutive `data:` lines are appended Because A2A clients stream SSE from remote, potentially untrusted agent servers, a malicious or broken server can exploit this as a **denial-of-service against the client**: - stream bytes that **never contain a `\n`** → the line `buffer` grows without bound - stream endless `data:` lines with **no terminating blank line** → `eventData` grows without bound Either way the client keeps allocating without bound. Today that can result in a process-level OOM crash that the caller cannot handle as a normal stream error. ## Fix Cap both accumulation points at `maxEventSizeBytes` and throw when exceeded. The throw runs the async generator's teardown, whose `finally` now cancels the reader (#580), so the offending connection is also closed. Net effect: a **process-level crash becomes a catchable `Error`** that flows into the transports' existing SSE-parse error handling. The same limit applies to the unterminated line buffer because a single SSE field line is necessarily part of an event and should not be allowed to exceed the maximum event budget on its own. - Default is **4 MiB** (matching gRPC's default max message size): realistic A2A events (Message/Task JSON) are KB-scale, and large files should be referenced via `FileWithUri` parts rather than inlined. - It is **configurable** via the new optional `maxEventSizeBytes` argument for callers that must inline larger payloads, and the error message points at both options. ## Open question — is this the right layer? I'm **not sure `parseSseStream` is the right place** for this, and would appreciate maintainer guidance. Alternatives worth considering: - enforcing it at the transport layer instead of the shared parser, - deriving/validating against `Content-Length` where present, - or leaving DoS mitigation entirely to the runtime / a reverse proxy. Happy to change the default, make it opt-in, or move it elsewhere based on what you prefer. ## Tests Adds regression tests for the unterminated-line, terminated-oversized-line (single-chunk), and unterminated-event cases — all would grow unbounded without the cap — plus a within-limit sanity check. `test/sse_utils.spec.ts` is green. - [x] Follows the `CONTRIBUTING` guide - [x] PR title uses Conventional Commits (`fix:`) - [x] Tests and linter pass - [ ] Docs updated (not necessary)
1 parent 83269a5 commit e6e8ce9

2 files changed

Lines changed: 97 additions & 1 deletion

File tree

src/sse_utils.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,39 @@ export function formatSSEErrorEvent(error: unknown): string {
6060
// SSE Event Parsing (Client-side)
6161
// ============================================================================
6262

63+
/**
64+
* Upper bound on how large a single unterminated line or a single SSE event
65+
* may grow before {@link parseSseStream} aborts. Measured in UTF-16 code
66+
* units (JS string length), which equals byte length for ASCII payloads such
67+
* as JSON-encoded events; memory stays bounded within a small constant
68+
* factor of this value either way.
69+
*
70+
* A2A clients stream from remote, potentially untrusted agent servers.
71+
* Without this cap a malicious or broken server can stream bytes that never
72+
* form a complete line — or `data:` lines whose blank-line terminator never
73+
* arrives — growing an in-memory buffer without limit until the client
74+
* process exhausts memory (CWE-400, uncontrolled resource consumption).
75+
*
76+
* Realistic A2A events (Message/Task JSON) are KB-scale, and large files
77+
* should be referenced via `FileWithUri` parts rather than inlined, so the
78+
* 4 MiB default (matching gRPC's default max message size) leaves ample
79+
* headroom. Callers that must inline larger payloads can raise it via the
80+
* `maxEventSizeBytes` argument.
81+
*/
82+
export const DEFAULT_MAX_SSE_EVENT_SIZE_BYTES = 4 * 1024 * 1024; // 4 MiB
83+
6384
/**
6485
* Parses an SSE stream from a `Response`, yielding events as they arrive.
6586
* Expects well-formed SSE events with single-line JSON data, matching the
6687
* format produced by {@link formatSSEEvent} and {@link formatSSEErrorEvent}.
88+
*
89+
* @param maxEventSizeBytes - Aborts the stream if a single line or event
90+
* exceeds this size, bounding memory against a hostile server. Defaults to
91+
* {@link DEFAULT_MAX_SSE_EVENT_SIZE_BYTES}.
6792
*/
6893
export async function* parseSseStream(
69-
response: Response
94+
response: Response,
95+
maxEventSizeBytes: number = DEFAULT_MAX_SSE_EVENT_SIZE_BYTES
7096
): AsyncGenerator<SseEvent, void, undefined> {
7197
if (!response.body) {
7298
throw new Error('SSE response body is undefined. Cannot read stream.');
@@ -83,6 +109,9 @@ export async function* parseSseStream(
83109
let lineEndIndex: number;
84110

85111
while ((lineEndIndex = buffer.indexOf('\n')) >= 0) {
112+
if (lineEndIndex > maxEventSizeBytes) {
113+
throw sseSizeError('SSE line', maxEventSizeBytes);
114+
}
86115
// Per the SSE spec lines may end with `\r\n`, `\r`, or `\n`. We
87116
// strip a trailing `\r` explicitly rather than calling `.trim()`,
88117
// which would also eat whitespace inside JSON-formatted `data:`
@@ -108,8 +137,17 @@ export async function* parseSseStream(
108137
// so append instead of overwriting.
109138
const fieldValue = stripOptionalLeadingSpace(line.substring('data:'.length));
110139
eventData = eventData === '' ? fieldValue : `${eventData}\n${fieldValue}`;
140+
if (eventData.length > maxEventSizeBytes) {
141+
throw sseSizeError('SSE event data', maxEventSizeBytes);
142+
}
111143
}
112144
}
145+
146+
// Same cap for a line that never terminates: the loop above never runs,
147+
// so the residual buffer would otherwise grow without bound.
148+
if (buffer.length > maxEventSizeBytes) {
149+
throw sseSizeError('SSE line', maxEventSizeBytes);
150+
}
113151
}
114152

115153
// Yield any pending event at stream end.
@@ -118,6 +156,13 @@ export async function* parseSseStream(
118156
}
119157
}
120158

159+
function sseSizeError(what: string, maxEventSizeBytes: number): Error {
160+
return new Error(
161+
`${what} exceeded the maximum allowed size of ${maxEventSizeBytes} bytes. ` +
162+
`Pass maxEventSizeBytes to raise the limit, or prefer FileWithUri parts for large payloads.`
163+
);
164+
}
165+
121166
/**
122167
* Per the SSE spec, the optional single leading space after the field-name
123168
* colon is consumed by the parser; embedded and trailing whitespace are

test/sse_utils.spec.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,4 +335,55 @@ describe('SSE Utils', () => {
335335
expect(JSON.parse(parsedEvents[1].data)).toEqual(errorEvent);
336336
});
337337
});
338+
339+
// A2A clients stream from remote, potentially untrusted servers. A hostile
340+
// server that never terminates a line — or an event — would grow an
341+
// in-memory buffer without bound (CWE-400). parseSseStream caps both.
342+
describe('parseSseStream size bound (DoS guard)', () => {
343+
async function drain(response: Response, maxEventSizeBytes?: number): Promise<SseEvent[]> {
344+
const events: SseEvent[] = [];
345+
for await (const event of parseSseStream(response, maxEventSizeBytes)) {
346+
events.push(event);
347+
}
348+
return events;
349+
}
350+
351+
it('throws when a single line never terminates and exceeds the limit', async () => {
352+
// No newline anywhere: the residual partial line grows past the cap.
353+
const response = createMockResponse('data: ' + 'A'.repeat(1000));
354+
355+
await expect(drain(response, 100)).rejects.toThrow(/SSE line exceeded the maximum/);
356+
});
357+
358+
it('throws when an oversized line is terminated and arrives in one chunk', async () => {
359+
// Delivered whole so the newline is present before the residual check —
360+
// exercises the in-loop guard the residual check alone would miss.
361+
const stream = createStream([new TextEncoder().encode(': ' + 'A'.repeat(1000) + '\n')]);
362+
const response = new Response(stream, {
363+
headers: { 'Content-Type': 'text/event-stream' },
364+
});
365+
366+
await expect(drain(response, 100)).rejects.toThrow(/SSE line exceeded the maximum/);
367+
});
368+
369+
it('throws when accumulated data lines exceed the limit before a blank line', async () => {
370+
// Many consecutive `data:` lines with no terminating blank line: the
371+
// joined event data grows past the cap.
372+
let sse = '';
373+
for (let i = 0; i < 200; i++) sse += `data: ${'A'.repeat(20)}\n`;
374+
const response = createMockResponse(sse);
375+
376+
await expect(drain(response, 100)).rejects.toThrow(/SSE event data exceeded the maximum/);
377+
});
378+
379+
it('parses a well-formed event that stays within the limit', async () => {
380+
const event = { kind: 'message', text: 'hello' };
381+
const response = createMockResponse(formatSSEEvent(event));
382+
383+
const events = await drain(response, 1024);
384+
385+
expect(events).toHaveLength(1);
386+
expect(JSON.parse(events[0].data)).toEqual(event);
387+
});
388+
});
338389
});

0 commit comments

Comments
 (0)