Skip to content

fix(mcp): stream MCP response body with running cap (no 16 MiB pre-rejection allocation)#4051

Merged
houko merged 2 commits into
mainfrom
fix/mcp-sse-streaming-cap
Apr 29, 2026
Merged

fix(mcp): stream MCP response body with running cap (no 16 MiB pre-rejection allocation)#4051
houko merged 2 commits into
mainfrom
fix/mcp-sse-streaming-cap

Conversation

@houko

@houko houko commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #3926 (cap SSE body).

Problem

read_response_bytes_capped used response.bytes().await — which buffers the full body in memory before the length check:

let b = response.bytes().await?;
if b.len() > MAX_RESPONSE_BYTES { return Err(...); }

A server omitting Content-Length (HTTP/1.1 chunked transfer) forces a fresh ~16 MiB allocation per request before rejection. Under concurrent abuse this creates real memory pressure — the audit of #3926 flagged it as a MEDIUM exploitation surface.

Fix

Stream via reqwest::Response::chunk() (already available in the crate's reqwest version, no new feature flag):

loop {
    match response.chunk().await {
        Ok(Some(chunk)) if buf.len() + chunk.len() > MAX_RESPONSE_BYTES => {
            return Err("...response aborted");
        }
        Ok(Some(chunk)) => buf.extend_from_slice(&chunk),
        Ok(None) => break,
        Err(e) => return Err(...),
    }
}
  • Pre-allocate Vec capacity from Content-Length when present, clamped to MAX_RESPONSE_BYTES so a malicious large hint can't bypass the streaming fix.
  • When Content-Length is missing the Vec grows organically; the running counter aborts the moment the next chunk would breach the cap.
  • Worst-case allocation is now MAX_RESPONSE_BYTES + 1 chunk size regardless of header presence — not the unconditional 16 MiB ceiling.

@github-actions github-actions Bot added the size/M 50-249 lines changed label Apr 28, 2026
@houko
houko force-pushed the fix/mcp-sse-streaming-cap branch 8 times, most recently from 728088d to 3569aca Compare April 29, 2026 07:10
houko added 2 commits April 29, 2026 17:02
…llocations

read_response_bytes_capped used response.bytes().await — which
buffers the FULL body in memory before checking the cap.  A server
that omits Content-Length (chunked transfer) forces a fresh
~16 MiB allocation per request before rejection, creating real
memory pressure under concurrent abuse.  The audit of #3926
flagged this as a MEDIUM exploitation surface.

Switch to streaming via reqwest::Response::chunk() (already in the
crate's reqwest version, no extra feature flag).  Loop:

  match response.chunk().await {
      Ok(Some(chunk)) if running + chunk > cap => err and abort
      Ok(Some(chunk)) => extend buf
      Ok(None) => break
      Err(e) => err
  }

Pre-allocate Vec capacity from Content-Length when present, clamped
to MAX_RESPONSE_BYTES so a malicious large hint can't bypass the
fix.  When Content-Length is missing the Vec grows organically; the
running counter still aborts the moment the next chunk would breach
the cap.  Worst-case allocation is now MAX_RESPONSE_BYTES + 1 chunk
size, not the buffered 16 MiB ceiling regardless of Content-Length.
…ed on 32-bit

Fast-path `content_length as usize > MAX_RESPONSE_BYTES` truncates a
u64 to usize on 32-bit / wasm32 targets, so a malicious
Content-Length: 0x1_0000_0001 casts to 1 and slips past the
"no bytes read" guarantee. The streaming loop's running counter
saves us from OOM, but the fast-path semantic is lost on non-64-bit
builds (notably the wasm32 sandbox tests). Compare in u64 instead.

The `hint.min(MAX_RESPONSE_BYTES) as usize` reserve has the same
shape — clamp in u64 before the cast for the same reason.
@houko
houko force-pushed the fix/mcp-sse-streaming-cap branch from 873da5b to f097854 Compare April 29, 2026 08:02

@houko houko left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

LGTM — streaming + running cap fixes the pre-rejection allocation cleanly. Cross-file check: minimax.rs:286/578 and gemini.rs:135 use the same name MAX_RESPONSE_BYTES but they're post-parse field-size checks on already-extracted base64 strings, not body buffering — different shape, out of scope here.

Two minor nits, neither a blocker:

  1. response.content_length() is called twice (fast-path reject + reserve hint). Cheap call, but the value could be bound once for clarity.

  2. PR description says "worst-case allocation is now MAX_RESPONSE_BYTES + 1 chunk size regardless of header presence" — strictly speaking, when an honest Content-Length is present we still reserve(min(cl, MAX)), so the upper bound is the same MAX as before. The real win is the no-header / chunked-transfer path, which previously allocated up to MAX unconditionally.

@houko
houko enabled auto-merge (squash) April 29, 2026 09:39
@houko
houko merged commit 440204f into main Apr 29, 2026
24 checks passed
@houko
houko deleted the fix/mcp-sse-streaming-cap branch April 29, 2026 09:39
@houko houko mentioned this pull request May 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/M 50-249 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant