Skip to content

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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions