[RIP-SEC] MCP client does not cap the HTTP/SSE response body on the default (Legacy) request, allowing a malicious MCP server to exhaust client memory

Open 💬 1 comment Opened Aug 5, 2026 by scadastrangelove

Summary

The rmcp HTTP transport applies its 8 MiB response cap (MAX_MCP_STDIO_LINE_BYTES) only when the
request is the capability Discover method or carries the modern protocol-version header
(2026-07-28). For the default/Legacy request the limit is None, and the body collector appends
every chunk with no ceiling. A malicious or MITM'd MCP server can return an arbitrarily large
HTTP/SSE body and exhaust the client's memory. The stdio transport caps at 8 MiB and enforces it,
showing the bound is intended.

Where

codex-rs/rmcp-client/src/http_client_adapter.rs:

let maximum_response_bytes = (mcp_method.as_deref() == Some(DiscoverRequestMethod::VALUE)
    || headers.get(HEADER_MCP_PROTOCOL_VERSION).and_then(|v| v.to_str().ok())
        == Some(ProtocolVersion::V_2026_07_28.as_str()))
    .then_some(MAX_MCP_STDIO_LINE_BYTES);   // None on the default/Legacy path

collect_body (same file) skips the size check entirely when maximum_bytes is None. Cap constant:
codex-rs/rmcp-client/src/local_stdio_transport.rs. Present on current main.

Reproduction

Exercising the exact cap selector and collect_body logic: for a Legacy request the computed limit is
None; collect_body(16 MiB, None) returns the full 16 MiB (unbounded) while
collect_body(16 MiB, Some(8 MiB)) returns ResponseTooLarge. In production the stream length is
attacker-controlled and unbounded.

Impact

Memory-exhaustion denial of service against a Codex client that connects to a malicious/MITM MCP
server over the default HTTP transport. DoS only — no code execution or data exposure.

Suggested fix

Apply the cap on all HTTP/SSE MCP responses (default maximum_response_bytes to the cap and widen it
deliberately), rather than defaulting to None for Legacy requests.

---
Found with the rust-in-peace pipeline
(AI-assisted Rust vulnerability research).

View original on GitHub ↗

1 Comment

scadastrangelove · 21 days ago

Candidate fix (with a regression test) for this issue. I can't open a PR directly —
openai/codex restricts pull requests to collaborators — so the patch is inline below,
and also on a branch you can pull/cherry-pick: https://github.com/scadastrangelove/codex/tree/codex-mcp-http-response-cap

<details><summary>Patch (git diff)</summary>

diff --git a/codex-rs/rmcp-client/src/http_client_adapter.rs b/codex-rs/rmcp-client/src/http_client_adapter.rs
index e147300..02eecf3 100644
--- a/codex-rs/rmcp-client/src/http_client_adapter.rs
+++ b/codex-rs/rmcp-client/src/http_client_adapter.rs
@@ -169,6 +169,10 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
                 .and_then(|value| value.to_str().ok())
                 == Some(ProtocolVersion::V_2026_07_28.as_str()))
         .then_some(MAX_MCP_STDIO_LINE_BYTES);
+        // Responses buffered wholesale by `collect_body` must always be bounded;
+        // see `buffered_response_cap`. (The SSE path below intentionally keeps
+        // `maximum_response_bytes`, which also signals modern-session framing.)
+        let buffered_response_bytes = buffered_response_cap(maximum_response_bytes);
         let redirect_policy = if mcp_method.as_deref() == Some(DiscoverRequestMethod::VALUE) {
             HttpRedirectPolicy::Stop
         } else {
@@ -258,7 +262,7 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
         let content_type = response_header(&response.headers, CONTENT_TYPE);
         let session_id = response_header(&response.headers, HEADER_SESSION_ID);
         if !status_is_success(response.status) {
-            let body = collect_body(&mut body_stream, maximum_response_bytes).await?;
+            let body = collect_body(&mut body_stream, buffered_response_bytes).await?;
             if !retryable_post_response_status(mcp_method.as_deref(), response.status)
                 && (content_type
                     .as_deref()
@@ -343,7 +347,7 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
                 Ok(StreamableHttpPostResponse::Sse(event_stream, session_id))
             }
             Some(content_type) if content_type.starts_with(JSON_MIME_TYPE) => {
-                let body = collect_body(&mut body_stream, maximum_response_bytes).await?;
+                let body = collect_body(&mut body_stream, buffered_response_bytes).await?;
                 let response_message = deserialize_incoming_jsonrpc_message(&body)
                     .map_err(StreamableHttpError::Deserialize)?;
                 Ok(StreamableHttpPostResponse::Json(
@@ -356,7 +360,7 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
                 ))
             }
             _ => {
-                let body = collect_body(&mut body_stream, maximum_response_bytes).await?;
+                let body = collect_body(&mut body_stream, buffered_response_bytes).await?;
                 let content_type = content_type.unwrap_or_else(|| "missing-content-type".into());
                 Err(StreamableHttpError::UnexpectedContentType(Some(format!(
                     "{content_type}; body: {}",
@@ -837,6 +841,18 @@ fn has_legacy_fallback_evidence(message: &str) -> bool {
         })
 }
 
+/// The size cap for a response that `collect_body` buffers entirely in memory.
+///
+/// `maximum_response_bytes` is only set for modern-session / discovery responses;
+/// it is `None` for legacy / unspecified-protocol responses, which previously
+/// left the buffered read unbounded — a malicious or buggy MCP server could
+/// stream an arbitrarily large body and OOM the client. Fall back to the same
+/// per-message limit already enforced on the stdio transport so every buffered
+/// response is bounded regardless of the negotiated protocol version.
+fn buffered_response_cap(maximum_response_bytes: Option<usize>) -> Option<usize> {
+    maximum_response_bytes.or(Some(MAX_MCP_STDIO_LINE_BYTES))
+}
+
 async fn collect_body(
     body_stream: &mut HttpResponseBodyStream,
     maximum_bytes: Option<usize>,
diff --git a/codex-rs/rmcp-client/src/http_client_adapter_tests.rs b/codex-rs/rmcp-client/src/http_client_adapter_tests.rs
index 7452f5b..cfed972 100644
--- a/codex-rs/rmcp-client/src/http_client_adapter_tests.rs
+++ b/codex-rs/rmcp-client/src/http_client_adapter_tests.rs
@@ -7,11 +7,27 @@ use http::header::AUTHORIZATION;
 use pretty_assertions::assert_eq;
 
 use super::HttpHeader;
+use super::MAX_MCP_STDIO_LINE_BYTES;
 use super::SseEventSizeLimit;
 use super::StreamableHttpRedirectMode;
+use super::buffered_response_cap;
 use super::mcp_redirect_policy;
 use super::protocol_headers;
 
+#[test]
+fn buffered_responses_are_always_capped() {
+    // Legacy / unspecified-protocol responses (`None`) must not be read
+    // unbounded: they fall back to the shared per-message limit so a malicious
+    // server cannot OOM the client by streaming an arbitrarily large body.
+    assert_eq!(
+        buffered_response_cap(None),
+        Some(MAX_MCP_STDIO_LINE_BYTES),
+        "buffered response with no protocol cap must fall back to a hard limit"
+    );
+    // An explicit protocol cap is preserved unchanged.
+    assert_eq!(buffered_response_cap(Some(1234)), Some(1234));
+}
+
 #[test]
 fn protocol_headers_preserve_utf8_values() {
     let mut headers = HeaderMap::new();

</details>

Built and tested on main @c87a218 with toolchain 1.95.0.
_Found with the rust-in-peace pipeline._