Skip to content

Unbounded line buffer in AsyncRwTransport::receive (stdio transport) — memory-exhaustion denial-of-service #1030

Closed

Description

@marvinbaudach

Summary

AsyncRwTransport::receive — the read path used by the stdio server transport (rmcp::transport::io::stdio()) and by the TokioChildProcess client transport — buffers an incoming line with BufReader::read_until(b'\n', &mut self.line_buf) and never checks a maximum length. A peer that sends a single line without a \n (or an extremely long line) makes the process grow line_buf: Vec<u8> without bound, exhausting memory. JsonRpcMessageCodec already has a max_length field with correct enforcement logic, but that logic lives in Decoder::decode, which AsyncRwTransport never calls — the transport's read side does not go through the codec's decoder at all.

Severity: Medium (denial-of-service, untrusted input, no data exposure).

Affected versions

Confirmed in rmcp 2.2.0 (current latest) and on main (byte-identical as of this report). The BufReader + line_buf: Vec<u8> + uncapped read_until pattern is present at least back to 1.8.0. Earlier releases (checked at 1.0.0) route receive() through FramedRead + Decoder::decode, but AsyncRwTransport::new always constructs JsonRpcMessageCodec::default() (max_length: usize::MAX) with no way for a caller to lower it — so those versions are equally unbounded via a different internal path.

Code reference

crates/rmcp/src/transport/async_rw.rs, AsyncRwTransport::receive:

async fn receive(&mut self) -> Option<RxJsonRpcMessage<Role>> {
    loop {
        match self.read.read_until(b'\n', &mut self.line_buf).await {
            Ok(0) => return None,
            Ok(_) => {}
            Err(e) => { tracing::error!("Error reading from stream: {}", e); return None; }
        }
        // line_buf is only cleared after a full '\n'-terminated line is parsed;
        // nothing bounds it before that.

line_buf is a plain Vec<u8> with no capacity ceiling. JsonRpcMessageCodec::max_length is fully implemented and correct inside Decoder::decode, but is unreachable from this transport.

Impact

Any stdio MCP server built with this SDK (.serve(rmcp::transport::io::stdio())), and any client using TokioChildProcess, accepts an unbounded amount of memory from a single unterminated or oversized input line from an untrusted peer — a straightforward denial-of-service.

Minimal reproduction

// Attacker / harness, writing to the server's stdin:
let chunk = vec![b'A'; 1 << 20]; // 1 MiB, contains no b'\n'
loop {
    stdin.write_all(&chunk).await?;
}
// The server process's RSS grows without bound and never shrinks, since
// line_buf is only cleared once a full '\n'-terminated line has been parsed.

Suggested fix

  • Give AsyncRwTransport::new/new_client/new_server a way to configure a maximum line length, and check self.line_buf.len() against it inside the read_until loop in receive, erroring out (and clearing the buffer) instead of appending past the limit; or
  • route reads back through JsonRpcMessageCodec's existing, correct max_length handling in Decoder::decode, while preserving the cancellation-safety behavior added in fix(transport): make AsyncRwTransport::receive cancel-safe (#941) #947.
  • A conservative built-in default (e.g. a few MiB) would close the gap for existing callers with no API change; exposing it as configurable would help callers who legitimately need larger single-line payloads.

Workaround

Until fixed, downstream consumers can wrap the reader passed to stdio()/serve() in their own size-capped AsyncRead that errors once a bounded number of bytes has been read without a newline.


Found while integrating the SDK into a desktop application. Happy to provide more detail, a runnable repro, or test against a candidate patch.

Activity

  1. self-assigned this
    on Jul 28, 2026
  2. added
    P2Medium: important but non-blocking improvement
    on Jul 28, 2026
  3. tbourrillon commented on Aug 17, 2026

    @tbourrillon

    Still present in 3.1.2 (current latest), unchanged: AsyncRwTransport::receive reads with self.read.read_until(b'\n', &mut self.line_buf) and nothing checks line_buf.len(). JsonRpcMessageCodec::max_length is public and correctly enforced in Decoder::decode, and that decoder is still not on this read path — so the knob exists and cannot be reached from the transport that needs it. Confirming from a client integration, where the affected path is TokioChildProcess.

    Two things that may help whoever picks this up:

    There is no seam for the workaround on the client side. The suggestion to wrap the reader works for a server built on transport::io::stdio(), but a TokioChildProcess client cannot take it: spawn() constructs the AsyncRwTransport internally and holds it in a private field, and the child_process helper that would hand back (stdout, stdin) is private. AsyncRwTransport::new is public, so a caller can build the transport over a size-capped reader — but only by spawning the child itself and reimplementing what ChildWithCleanup does for kill-on-drop, which is a poor trade for a memory bound. A public constructor taking a max length (the first of the two suggested fixes) would resolve that as well as the bound itself.

    The precedent for the fix is already in the crate. bounded_sse_stream (transport/common/client_side_sse.rs) bounds every SSE event against DEFAULT_MAX_SSE_EVENT_SIZE (16 MiB) and fails with EventTooLarge { max_size }, and max_sse_event_size is exposed on StreamableHttpClientTransportConfig. So "cap a single incoming message, make the cap configurable, name the limit in the error" is a decision this project has already made once — this issue is the same decision not yet applied to the stdio read path.

    Filed the HTTP counterpart as #1180: on the streamable-HTTP POST path, the SSE branch is bounded by bounded_sse_stream, but the application/json success body (response.json()) and any non-success body (response.text(), then interpolated whole into an error message) are not. Different file and different fix, so it seemed better as its own issue than as a comment here — happy to consolidate if you would rather track "bound every read" as one item.

  4. added a commit that references this issue on Oct 14, 2026
    8d226f4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P2Medium: important but non-blocking improvement

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions