Repository navigation
Unbounded line buffer in AsyncRwTransport::receive (stdio transport) — memory-exhaustion denial-of-service #1030
Description
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_servera way to configure a maximum line length, and checkself.line_buf.len()against it inside theread_untilloop inreceive, erroring out (and clearing the buffer) instead of appending past the limit; or - route reads back through
JsonRpcMessageCodec's existing, correctmax_lengthhandling inDecoder::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
docs(upstream): record the rmcp stdio unbounded-line-buffer report (i…
tbourrillon commentedon Aug 17, 2026
Still present in 3.1.2 (current latest), unchanged:
AsyncRwTransport::receivereads withself.read.read_until(b'\n', &mut self.line_buf)and nothing checksline_buf.len().JsonRpcMessageCodec::max_lengthis public and correctly enforced inDecoder::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 isTokioChildProcess.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 aTokioChildProcessclient cannot take it:spawn()constructs theAsyncRwTransportinternally and holds it in a private field, and thechild_processhelper that would hand back(stdout, stdin)is private.AsyncRwTransport::newis public, so a caller can build the transport over a size-capped reader — but only by spawning the child itself and reimplementing whatChildWithCleanupdoes 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 againstDEFAULT_MAX_SSE_EVENT_SIZE(16 MiB) and fails withEventTooLarge { max_size }, andmax_sse_event_sizeis exposed onStreamableHttpClientTransportConfig. 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 theapplication/jsonsuccess 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.fix(transport): bound the incoming line buffer in AsyncRwTransport (#…