Skip to content

fix(client): bound SSE event size to prevent client-side memory exhaustion#582

Merged
JakubWorek merged 4 commits into
a2aproject:epic/1.0_breaking_changesfrom
RenKoya1:fix/bound-sse-event-size
Jul 16, 2026
Merged

fix(client): bound SSE event size to prevent client-side memory exhaustion#582
JakubWorek merged 4 commits into
a2aproject:epic/1.0_breaking_changesfrom
RenKoya1:fix/bound-sse-event-size

Conversation

@RenKoya1

@RenKoya1 RenKoya1 commented Jul 16, 2026

Copy link
Copy Markdown

Description

parseSseStream (shared by the JSON-RPC and REST client transports)
accumulates two buffers with no size bound:

  • the line bufferbuffer += 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 lineeventData 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.

  • Follows the CONTRIBUTING guide
  • PR title uses Conventional Commits (fix:)
  • Tests and linter pass
  • Docs updated (not necessary)

…stion

parseSseStream accumulated both the line buffer and an event's joined
data with no size limit. A2A clients stream from remote, potentially
untrusted agent servers; a malicious or broken server could stream bytes
that never form a complete line, or endless `data:` lines with no
terminating blank line, growing an in-memory buffer without bound until
the client process runs out of memory (CWE-400).

Cap both accumulation points at a configurable maxEventSizeBytes
(default 20 MiB, generous enough for legitimate base64 file parts) and
throw when exceeded. The throw triggers the generator's teardown, whose
finally now cancels the reader (a2aproject#580), so the offending connection is
also closed. Adds regression tests for the unterminated-line and
unterminated-event cases plus a within-limit sanity check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@RenKoya1
RenKoya1 requested a review from a team as a code owner July 16, 2026 06:51
@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

🧪 Code Coverage

⬇️ Download Full Report

Base PR Delta
src/sse_utils.ts 93.93% 95.12% 🟢 +1.19%
Total 90.73% 90.75% 🟢 +0.02%

Generated by coverage-comment.yml

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a size-bounding mechanism to parseSseStream to protect against uncontrolled resource consumption (CWE-400) from untrusted SSE servers, along with corresponding unit tests. However, a security bypass vulnerability was identified where a very large terminated line can bypass the buffer size check because it is currently evaluated outside the processing loop. It is recommended to enforce the limit inside the loop before extracting the line and to add a test case verifying this scenario.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/sse_utils.ts
Comment thread test/sse_utils.spec.ts
The residual buffer check ran after the line-split loop, so a large but
terminated line (e.g. a huge `:` comment or `event:` line ending in \n)
was drained by the loop and slipped past the cap. Enforce the limit
inside the loop on the line length before extracting it, and add a
regression test that delivers such a line in a single chunk (the 2-byte
mock chunking would otherwise trip the residual check first).

Addresses gemini-code-assist review on a2aproject#582.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@RenKoya1

Copy link
Copy Markdown
Author

Good catch — fixed in f16160c. The residual buffer.length check only fired for lines that never terminate; a large terminated line was drained by the split loop and slipped past. Now the limit is enforced inside the loop on lineEndIndex before extracting the line.

One note on the suggested test: with the 2-byte createMockResponse chunking, an oversized line trips the residual check before its newline arrives, so it would pass even without the fix. I added a regression test that delivers the whole terminated line in a single chunk — it fails on the pre-fix code and passes now, so it actually exercises the in-loop guard.

String .length under-counts UTF-8 wire bytes for non-ASCII, so calling
it a conservative over-approximation was backwards. State the actual
unit and that memory stays bounded within a constant factor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/sse_utils.ts
Realistic A2A events (Message/Task JSON) are KB-scale and large files
should be referenced via FileWithUri parts rather than inlined, so 4 MiB
(matching gRPC's default max message size) leaves ample headroom. Also
make the error actionable: point at maxEventSizeBytes and FileWithUri.

Addresses review feedback from JakubWorek on a2aproject#582.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@JakubWorek
JakubWorek merged commit e6e8ce9 into a2aproject:epic/1.0_breaking_changes Jul 16, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants