Skip to content

fix(mcp): kill stdio child on drop, cap SSE body, pipe stderr, restrict env expansion#3926

Merged
houko merged 5 commits into
mainfrom
fix/mcp-3800-3801-3805-3823
Apr 28, 2026
Merged

fix(mcp): kill stdio child on drop, cap SSE body, pipe stderr, restrict env expansion#3926
houko merged 5 commits into
mainfrom
fix/mcp-3800-3801-3805-3823

Conversation

@houko

@houko houko commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Test plan

  • Hot-reload MCP server — old child exits
  • MCP server with >16 MB response is rejected
  • MCP child stderr appears in debug logs
  • Undeclared $SECRET_KEY in MCP command stays unexpanded

…r, restrict env expansion

tokio runtime when the connection is dropped without an explicit close call.
Also update disconnect_mcp_server in the kernel to call conn.close().await
explicitly after extracting connections from the lock, ensuring the stdio
child is reaped before hot-reload brings up a new connection.

SSE and HttpCompat transports. Checks Content-Length first (fast path) then
reads up to 16 MiB; rejects oversized bodies with an error instead of OOMing.

TokioChildProcess builder instead of inheriting the daemon fd. A background
drain task reads the pipe and logs each line at DEBUG with a 256-byte cap
and a 100-line hard stop, preventing pipe stalls without flooding logs.

(safe system vars + the operator-declared env entries). $VAR tokens not in
the allowlist are left verbatim — std::env::var() is never called for them,
so daemon secrets (ANTHROPIC_API_KEY, GROQ_API_KEY, etc.) cannot leak into
child process arguments even if they happen to be referenced in a template.

@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.

Two notes from the automated daily review pass — nothing blocking, both are clarifications/cleanups rather than correctness issues.

The implementation itself looks solid: explicit close().await before releasing the lock in disconnect_mcp_server and reconcile_mcp_servers is the right approach for subprocess reaping (#3800); the body-cap fast-path on Content-Length is good (#3801); the stderr drain with line/count caps is well-bounded (#3805); and the env-var allowlist correctly blocks undeclared daemon secrets from leaking into MCP child args (#3823). Tests cover all the new paths cleanly.


Generated by Claude Code

// Reject responses whose Content-Type is neither JSON nor an SSE
// stream — anything else is almost certainly a proxy error page or a
// misconfigured server, and decoding it as JSON-RPC would silently
// produce garbage. (#3802)

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.

Issue reference mismatch: the comment says (#3802) but the PR title lists fix/mcp-3800-3801-3805-3823 (no #3802), and the PR description attributes the Content-Type validation to closes #3801.

If Content-Type validation really belongs to a separate issue #3802, please add it to the PR title and Fixes #3802 in the description so it auto-closes. If it's part of #3801, change the comment here to (#3801) to keep things consistent.


Generated by Claude Code

let inner = std::mem::replace(
&mut self.inner,
McpInner::HttpCompat {
client: reqwest::Client::new(),

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.

mem::replace needs a value to put back, so this constructs a reqwest::Client purely as a sentinel — an HTTP client with a connection pool — just to satisfy the type system. The logic is correct but it's wasteful and reads oddly in a Drop impl.

A cleaner approach is to add a McpInner::Closed variant:

enum McpInner {
    Rmcp(DynRmcpClient),
    Sse { client: reqwest::Client, url: String, next_id: u64 },
    HttpCompat { client: reqwest::Client },
    Closed, // sentinel used only by Drop
}

Then Drop becomes:

let inner = std::mem::replace(&mut self.inner, McpInner::Closed);

No allocation, no misleading reqwest::Client::new() call, and any future match on McpInner that forgets to handle Closed will get a compile-time exhaustiveness error rather than silently operating on a ghost client.


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: be37362e95

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

let mut lines_read: u32 = 0;
const MAX_LINE_BYTES: usize = 256;
const MAX_LINES: u32 = 100;
while lines_read < MAX_LINES {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep draining stderr after reaching log line cap

The drain task stops entirely once lines_read reaches MAX_LINES, which leaves the child process's stderr pipe unread for the rest of its lifetime. In deployments where an MCP server continues writing diagnostics after startup, the pipe buffer can fill and block the child on stderr writes, causing stalled/hung MCP tool calls even though the connection is still considered active. Continue consuming stderr after the cap and only suppress logging output.

Useful? React with 👍 / 👎.

let server_name_for_log = resolved_command.clone();
tokio::spawn(async move {
use tokio::io::BufReader;
let mut reader = BufReader::new(stderr).lines();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bound stderr read size before allocating each line

The 256-byte truncation is applied only after next_line() returns, but tokio's line reader buffers the entire line first. A misbehaving or malicious MCP subprocess that emits a very large line without a newline can therefore force unbounded allocation in the daemon before truncation logic runs, creating an OOM risk. Use a bounded read strategy that enforces the limit during IO, not after line materialization.

Useful? React with 👍 / 👎.

@github-actions github-actions Bot added the area/kernel Core kernel (scheduling, RBAC, workflows) label Apr 28, 2026
@github-actions github-actions Bot added the size/L 250-999 lines changed label Apr 28, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2bd9fc1013

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +812 to +816
let b = response
.bytes()
.await
.map_err(|e| format!("Failed to read response body: {e}"))?;
if b.len() > MAX_RESPONSE_BYTES {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stream response with cap instead of buffering full body

read_response_bytes_capped still calls response.bytes() before checking b.len(), so for responses without a trustworthy Content-Length (e.g., chunked transfer) the client buffers the entire payload in memory first. A malicious MCP server can therefore still force large allocations/OOM despite the 16 MiB guard, because the cap is enforced only after full materialization.

Useful? React with 👍 / 👎.

houko and others added 3 commits April 28, 2026 15:42
On Windows, dropping the TCP server task before reading the request
aborts the connection (WSAECONNABORTED, code 10053) and reqwest's
.send() returns an Io error instead of the response. Read up to 1 KB
of the request first so the client's send() completes cleanly.
@houko
houko merged commit ba92224 into main Apr 28, 2026
1 check passed
@houko
houko deleted the fix/mcp-3800-3801-3805-3823 branch April 28, 2026 13:37
houko added a commit that referenced this pull request Apr 28, 2026
#3926's stderr drain loop terminated at line 101:

    while lines_read < MAX_LINES {
        match reader.next_line().await { ... lines_read += 1; ... }
    }

After the 100th line the task exits and stops reading the pipe.  Once
the kernel pipe buffer fills (64 KiB on Linux), the child's next
write(stderr) blocks forever — re-creating the exact pipe-stall
failure mode #3926 claimed to fix.  Any moderately chatty MCP server
(e.g. one that prints a banner + per-request log) hits this within
seconds.

Switch to a  that keeps reading after MAX_LINES but skips
the format/log work:

  - lines_read < MAX_LINES → format and debug! the line as before.
  - lines_read == MAX_LINES → log a single 'capped, still draining'
    notice so an operator can correlate.
  - lines_read > MAX_LINES → next_line(), discard, continue.

The pipe stays drained indefinitely.  The only break paths are now
EOF (child closed stderr) and a real I/O error (pipe unusable),
which is the only correct shutdown signal.
houko added a commit that referenced this pull request Apr 28, 2026
…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.
houko added a commit that referenced this pull request Apr 28, 2026
…n stall

The audit of #3926 caught two real properties of the stdio shutdown
path:

  1. tokio Command's kill_on_drop(true) only sends SIGKILL — it does
     NOT call wait().  The child becomes a zombie until tokio's
     internal child reaper task runs.  If the reaper is itself
     starved (runtime spinning down) or already dropped (synchronous
     drop on a runtime that's been ejected), the zombie persists
     until the daemon process itself exits.

  2. McpConnection::close() awaits client.close() unconditionally —
     a wedged rmcp transport loop blocks the caller forever.  Hot
     reload (which awaits this) and daemon graceful shutdown both
     accumulate stalls there.

A full fix for the kill_on_drop / no-wait gap requires hijacking
the child handle out of rmcp's RunningService, which means either
patching rmcp upstream or replacing the transport entirely — far
outside follow-up scope.  The contained improvement is bounding
both close paths so the daemon stops betting on the tokio reaper:

  * Explicit close (close()): wrap client.close() in a 10s
    tokio::time::timeout.  Healthy servers complete easily; a stuck
    one stops blocking hot-reload.
  * Implicit close (Drop): same 10s timeout inside the spawn so a
    wedged transport doesn't pin a tokio worker.

10s is generous enough that a normally-shutting child finishes
without a warning but tight enough that 'still running 10 seconds
after kill' is the right signal that something failed and we
should rely on OS-level reap-on-process-exit.
houko added a commit that referenced this pull request Apr 29, 2026
#3926's stderr drain loop terminated at line 101:

    while lines_read < MAX_LINES {
        match reader.next_line().await { ... lines_read += 1; ... }
    }

After the 100th line the task exits and stops reading the pipe.  Once
the kernel pipe buffer fills (64 KiB on Linux), the child's next
write(stderr) blocks forever — re-creating the exact pipe-stall
failure mode #3926 claimed to fix.  Any moderately chatty MCP server
(e.g. one that prints a banner + per-request log) hits this within
seconds.

Switch to a  that keeps reading after MAX_LINES but skips
the format/log work:

  - lines_read < MAX_LINES → format and debug! the line as before.
  - lines_read == MAX_LINES → log a single 'capped, still draining'
    notice so an operator can correlate.
  - lines_read > MAX_LINES → next_line(), discard, continue.

The pipe stays drained indefinitely.  The only break paths are now
EOF (child closed stderr) and a real I/O error (pipe unusable),
which is the only correct shutdown signal.
houko added a commit that referenced this pull request Apr 29, 2026
…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.
houko added a commit that referenced this pull request Apr 29, 2026
…n stall

The audit of #3926 caught two real properties of the stdio shutdown
path:

  1. tokio Command's kill_on_drop(true) only sends SIGKILL — it does
     NOT call wait().  The child becomes a zombie until tokio's
     internal child reaper task runs.  If the reaper is itself
     starved (runtime spinning down) or already dropped (synchronous
     drop on a runtime that's been ejected), the zombie persists
     until the daemon process itself exits.

  2. McpConnection::close() awaits client.close() unconditionally —
     a wedged rmcp transport loop blocks the caller forever.  Hot
     reload (which awaits this) and daemon graceful shutdown both
     accumulate stalls there.

A full fix for the kill_on_drop / no-wait gap requires hijacking
the child handle out of rmcp's RunningService, which means either
patching rmcp upstream or replacing the transport entirely — far
outside follow-up scope.  The contained improvement is bounding
both close paths so the daemon stops betting on the tokio reaper:

  * Explicit close (close()): wrap client.close() in a 10s
    tokio::time::timeout.  Healthy servers complete easily; a stuck
    one stops blocking hot-reload.
  * Implicit close (Drop): same 10s timeout inside the spawn so a
    wedged transport doesn't pin a tokio worker.

10s is generous enough that a normally-shutting child finishes
without a warning but tight enough that 'still running 10 seconds
after kill' is the right signal that something failed and we
should rely on OS-level reap-on-process-exit.
houko added a commit that referenced this pull request Apr 29, 2026
…all (#4045)

#3926's stderr drain loop terminated at line 101:

    while lines_read < MAX_LINES {
        match reader.next_line().await { ... lines_read += 1; ... }
    }

After the 100th line the task exits and stops reading the pipe.  Once
the kernel pipe buffer fills (64 KiB on Linux), the child's next
write(stderr) blocks forever — re-creating the exact pipe-stall
failure mode #3926 claimed to fix.  Any moderately chatty MCP server
(e.g. one that prints a banner + per-request log) hits this within
seconds.

Switch to a  that keeps reading after MAX_LINES but skips
the format/log work:

  - lines_read < MAX_LINES → format and debug! the line as before.
  - lines_read == MAX_LINES → log a single 'capped, still draining'
    notice so an operator can correlate.
  - lines_read > MAX_LINES → next_line(), discard, continue.

The pipe stays drained indefinitely.  The only break paths are now
EOF (child closed stderr) and a real I/O error (pipe unusable),
which is the only correct shutdown signal.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
houko added a commit that referenced this pull request Apr 29, 2026
…n stall

The audit of #3926 caught two real properties of the stdio shutdown
path:

  1. tokio Command's kill_on_drop(true) only sends SIGKILL — it does
     NOT call wait().  The child becomes a zombie until tokio's
     internal child reaper task runs.  If the reaper is itself
     starved (runtime spinning down) or already dropped (synchronous
     drop on a runtime that's been ejected), the zombie persists
     until the daemon process itself exits.

  2. McpConnection::close() awaits client.close() unconditionally —
     a wedged rmcp transport loop blocks the caller forever.  Hot
     reload (which awaits this) and daemon graceful shutdown both
     accumulate stalls there.

A full fix for the kill_on_drop / no-wait gap requires hijacking
the child handle out of rmcp's RunningService, which means either
patching rmcp upstream or replacing the transport entirely — far
outside follow-up scope.  The contained improvement is bounding
both close paths so the daemon stops betting on the tokio reaper:

  * Explicit close (close()): wrap client.close() in a 10s
    tokio::time::timeout.  Healthy servers complete easily; a stuck
    one stops blocking hot-reload.
  * Implicit close (Drop): same 10s timeout inside the spawn so a
    wedged transport doesn't pin a tokio worker.

10s is generous enough that a normally-shutting child finishes
without a warning but tight enough that 'still running 10 seconds
after kill' is the right signal that something failed and we
should rely on OS-level reap-on-process-exit.
houko added a commit that referenced this pull request Apr 29, 2026
…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.
houko added a commit that referenced this pull request Apr 29, 2026
…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.
houko added a commit that referenced this pull request Apr 29, 2026
…n stall

The audit of #3926 caught two real properties of the stdio shutdown
path:

  1. tokio Command's kill_on_drop(true) only sends SIGKILL — it does
     NOT call wait().  The child becomes a zombie until tokio's
     internal child reaper task runs.  If the reaper is itself
     starved (runtime spinning down) or already dropped (synchronous
     drop on a runtime that's been ejected), the zombie persists
     until the daemon process itself exits.

  2. McpConnection::close() awaits client.close() unconditionally —
     a wedged rmcp transport loop blocks the caller forever.  Hot
     reload (which awaits this) and daemon graceful shutdown both
     accumulate stalls there.

A full fix for the kill_on_drop / no-wait gap requires hijacking
the child handle out of rmcp's RunningService, which means either
patching rmcp upstream or replacing the transport entirely — far
outside follow-up scope.  The contained improvement is bounding
both close paths so the daemon stops betting on the tokio reaper:

  * Explicit close (close()): wrap client.close() in a 10s
    tokio::time::timeout.  Healthy servers complete easily; a stuck
    one stops blocking hot-reload.
  * Implicit close (Drop): same 10s timeout inside the spawn so a
    wedged transport doesn't pin a tokio worker.

10s is generous enough that a normally-shutting child finishes
without a warning but tight enough that 'still running 10 seconds
after kill' is the right signal that something failed and we
should rely on OS-level reap-on-process-exit.
houko added a commit that referenced this pull request Apr 29, 2026
…n stall (#4052)

The audit of #3926 caught two real properties of the stdio shutdown
path:

  1. tokio Command's kill_on_drop(true) only sends SIGKILL — it does
     NOT call wait().  The child becomes a zombie until tokio's
     internal child reaper task runs.  If the reaper is itself
     starved (runtime spinning down) or already dropped (synchronous
     drop on a runtime that's been ejected), the zombie persists
     until the daemon process itself exits.

  2. McpConnection::close() awaits client.close() unconditionally —
     a wedged rmcp transport loop blocks the caller forever.  Hot
     reload (which awaits this) and daemon graceful shutdown both
     accumulate stalls there.

A full fix for the kill_on_drop / no-wait gap requires hijacking
the child handle out of rmcp's RunningService, which means either
patching rmcp upstream or replacing the transport entirely — far
outside follow-up scope.  The contained improvement is bounding
both close paths so the daemon stops betting on the tokio reaper:

  * Explicit close (close()): wrap client.close() in a 10s
    tokio::time::timeout.  Healthy servers complete easily; a stuck
    one stops blocking hot-reload.
  * Implicit close (Drop): same 10s timeout inside the spawn so a
    wedged transport doesn't pin a tokio worker.

10s is generous enough that a normally-shutting child finishes
without a warning but tight enough that 'still running 10 seconds
after kill' is the right signal that something failed and we
should rely on OS-level reap-on-process-exit.
houko added a commit that referenced this pull request Apr 29, 2026
…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.
houko added a commit that referenced this pull request Apr 29, 2026
…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.
houko added a commit that referenced this pull request Apr 29, 2026
…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.
houko added a commit that referenced this pull request Apr 29, 2026
…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.
houko added a commit that referenced this pull request Apr 29, 2026
…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.
houko added a commit that referenced this pull request Apr 29, 2026
…jection allocation) (#4051)

* fix(mcp): stream MCP response body with running cap to avoid 16 MiB allocations

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.

* fix(mcp): compare Content-Length in u64 to keep fast-path bypass closed 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 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

area/kernel Core kernel (scheduling, RBAC, workflows) size/L 250-999 lines changed

Projects

None yet

1 participant