Skip to content

Commit 6d2e575

Browse files
committed
fix(transport): bound the incoming line buffer in AsyncRwTransport
`AsyncRwTransport::receive` read a line with `read_until`, which has no ceiling, so a peer that never sends a newline makes `line_buf` grow until the process runs out of memory. This affects every stdio server built on `rmcp::transport::io::stdio()` and every client using `TokioChildProcess`. `JsonRpcMessageCodec` already implements this bound correctly, but that logic lives in `Decoder::decode`, which this transport never calls. Reading through `fill_buf`/`consume` lets the limit be checked before each append, which `read_until` cannot do: it does not return until it reaches a delimiter or EOF, so by the time its result could be inspected the memory is already committed. An oversized line is dropped and logged and the connection stays open, matching the discard behaviour the codec's decoder already uses. The default is 16 MiB, the same bound the streamable HTTP client applies to a single SSE event, so a payload that is acceptable over HTTP is also acceptable over stdio. `with_max_line_length` overrides it and `usize::MAX` restores the previous unbounded behaviour. Cancellation safety is preserved. `fill_buf` consumes nothing if the future is dropped, and the copy into `line_buf` and the matching `consume` have no await between them, so a partially read line still survives to the next call, which is what #947 relies on. Fixes #1030
1 parent 14298b7 commit 6d2e575

2 files changed

Lines changed: 345 additions & 14 deletions

File tree

crates/rmcp/src/transport/async_rw.rs

Lines changed: 140 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,38 @@ where
4848

4949
pub type TransportWriter<Role, W> = FramedWrite<W, JsonRpcMessageCodec<TxJsonRpcMessage<Role>>>;
5050

51+
/// Default cap on the size of a single incoming line (one JSON-RPC message) for
52+
/// [`AsyncRwTransport`].
53+
///
54+
/// Without a cap, a peer that never sends a newline makes the read buffer grow
55+
/// until the process runs out of memory. 16 MiB matches the streamable HTTP
56+
/// client's `DEFAULT_MAX_SSE_EVENT_SIZE`, so a payload that is acceptable over
57+
/// HTTP is also acceptable over stdio.
58+
pub const DEFAULT_MAX_LINE_LENGTH: usize = 16 * 1024 * 1024;
59+
5160
pub struct AsyncRwTransport<Role: ServiceRole, R: AsyncRead, W: AsyncWrite> {
5261
read: BufReader<R>,
5362
line_buf: Vec<u8>,
63+
max_line_length: usize,
64+
/// Set once an oversized line has been rejected, until its terminating
65+
/// newline is seen. Lives on the struct rather than in `read_line` because
66+
/// `receive` is polled inside a `select!` and can be cancelled mid-discard.
67+
discarding: bool,
5468
write: Arc<Mutex<Option<TransportWriter<Role, W>>>>,
5569
_role: PhantomData<fn() -> Role>,
5670
}
5771

72+
/// Outcome of a single bounded line read.
73+
enum LineRead {
74+
/// A whole `\n`-terminated line is in `line_buf`.
75+
Line,
76+
/// The line exceeded `max_line_length`; it has been dropped.
77+
Oversized,
78+
/// The peer closed the stream.
79+
Eof,
80+
Io(std::io::Error),
81+
}
82+
5883
impl<Role: ServiceRole, R, W> AsyncRwTransport<Role, R, W>
5984
where
6085
R: Send + AsyncRead + Unpin,
@@ -69,10 +94,105 @@ where
6994
Self {
7095
read,
7196
line_buf: Vec::new(),
97+
max_line_length: DEFAULT_MAX_LINE_LENGTH,
98+
discarding: false,
7299
write,
73100
_role: PhantomData,
74101
}
75102
}
103+
104+
/// Override the maximum size of a single incoming line.
105+
///
106+
/// Defaults to [`DEFAULT_MAX_LINE_LENGTH`]. Lines longer than this are
107+
/// dropped and logged rather than buffered, and the connection stays open.
108+
/// `usize::MAX` restores the previous unbounded behaviour.
109+
pub fn with_max_line_length(mut self, max_line_length: usize) -> Self {
110+
self.max_line_length = max_line_length;
111+
self
112+
}
113+
114+
/// Read one `\n`-terminated line into `self.line_buf` without ever letting
115+
/// it grow past `self.max_line_length`.
116+
///
117+
/// This replaces `read_until`, which cannot be bounded: it does not return
118+
/// until it reaches a delimiter or EOF, so by the time its length could be
119+
/// inspected the memory has already been committed. Reading through
120+
/// `fill_buf`/`consume` lets the limit be checked before each append.
121+
///
122+
/// Cancellation safety is preserved. `fill_buf` consumes nothing if the
123+
/// future is dropped, and the copy into `line_buf` and the matching
124+
/// `consume` are synchronous with no await between them, so a cancelled
125+
/// read leaves a partial line in `line_buf` for the next call to resume,
126+
/// exactly as `read_until` did.
127+
async fn read_line(&mut self) -> LineRead {
128+
loop {
129+
// The borrow of `self.read` taken by `fill_buf` has to end before
130+
// `consume` can be called, so the decision is made in this block
131+
// and applied after it.
132+
let (consumed, step) = {
133+
let available = match self.read.fill_buf().await {
134+
Ok(available) => available,
135+
Err(e) => return LineRead::Io(e),
136+
};
137+
if available.is_empty() {
138+
return LineRead::Eof;
139+
}
140+
141+
let newline = available.iter().position(|b| *b == b'\n');
142+
let take = newline.map_or(available.len(), |idx| idx + 1);
143+
144+
if self.discarding {
145+
// Still dropping the tail of a line already rejected.
146+
(
147+
take,
148+
newline.map(|_| Step::EndDiscard).unwrap_or(Step::More),
149+
)
150+
} else if self.line_buf.len().saturating_add(take) > self.max_line_length {
151+
self.line_buf.clear();
152+
(
153+
take,
154+
if newline.is_some() {
155+
// The oversized line ends here, nothing left to skip.
156+
Step::Oversized
157+
} else {
158+
Step::OversizedNeedsDiscard
159+
},
160+
)
161+
} else {
162+
self.line_buf.extend_from_slice(&available[..take]);
163+
(
164+
take,
165+
if newline.is_some() {
166+
Step::Line
167+
} else {
168+
Step::More
169+
},
170+
)
171+
}
172+
};
173+
self.read.consume(consumed);
174+
175+
match step {
176+
Step::Line => return LineRead::Line,
177+
Step::More => {}
178+
Step::EndDiscard => self.discarding = false,
179+
Step::Oversized => return LineRead::Oversized,
180+
Step::OversizedNeedsDiscard => {
181+
self.discarding = true;
182+
return LineRead::Oversized;
183+
}
184+
}
185+
}
186+
}
187+
}
188+
189+
/// What to do once the `fill_buf` borrow has been released.
190+
enum Step {
191+
Line,
192+
More,
193+
EndDiscard,
194+
Oversized,
195+
OversizedNeedsDiscard,
76196
}
77197

78198
#[cfg(feature = "client")]
@@ -124,22 +244,28 @@ where
124244

125245
async fn receive(&mut self) -> Option<RxJsonRpcMessage<Role>> {
126246
loop {
127-
// `read_until` is not cancellation-safe on its own, and `receive` is
128-
// polled inside a `select!` in the service loop: an in-progress line
129-
// read is dropped whenever another branch (e.g. an outgoing response)
130-
// becomes ready. We rely on `read_until` appending into `self.line_buf`
131-
// and only returning at a delimiter or EOF, so a cancelled read leaves
132-
// its partial bytes in `self.line_buf`. Keeping that buffer across
133-
// calls lets the next read resume the same line; it is cleared only
134-
// after a whole line has been consumed. Clearing at the top of the
135-
// loop (the previous behaviour) discarded the partial read and so
136-
// dropped incoming requests under concurrent response load.
137-
match self.read.read_until(b'\n', &mut self.line_buf).await {
247+
// `receive` is polled inside a `select!` in the service loop, so an
248+
// in-progress line read is dropped whenever another branch (e.g. an
249+
// outgoing response) becomes ready. `read_line` appends into
250+
// `self.line_buf` and only reports a line once it hits a delimiter,
251+
// so a cancelled read leaves its partial bytes there and the next
252+
// call resumes the same line; the buffer is cleared only after a
253+
// whole line has been consumed. Clearing at the top of the loop (the
254+
// behaviour before #947) discarded the partial read and so dropped
255+
// incoming requests under concurrent response load.
256+
match self.read_line().await {
257+
LineRead::Line => {}
258+
LineRead::Oversized => {
259+
tracing::error!(
260+
max_line_length = self.max_line_length,
261+
"Incoming message exceeded the maximum line length, discarding it"
262+
);
263+
continue;
264+
}
138265
// EOF. Any bytes still in `line_buf` are an incomplete trailing
139266
// message with no delimiter, so there is nothing to deliver.
140-
Ok(0) => return None,
141-
Ok(_) => {}
142-
Err(e) => {
267+
LineRead::Eof => return None,
268+
LineRead::Io(e) => {
143269
tracing::error!("Error reading from stream: {}", e);
144270
return None;
145271
}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
//! Regression tests for the line-length bound on `AsyncRwTransport`.
2+
//!
3+
//! The stdio server transport and the `TokioChildProcess` client transport both
4+
//! read through `AsyncRwTransport`. Before this bound existed, the read side
5+
//! buffered an incoming line with no ceiling, so a peer that sent an
6+
//! unterminated or oversized line could grow the process's memory until it was
7+
//! killed. See https://github.com/modelcontextprotocol/rust-sdk/issues/1030.
8+
9+
use rmcp::{
10+
RoleServer,
11+
transport::{
12+
Transport,
13+
async_rw::{AsyncRwTransport, DEFAULT_MAX_LINE_LENGTH},
14+
},
15+
};
16+
use tokio::io::{AsyncWriteExt, DuplexStream};
17+
18+
const MAX: usize = 4 * 1024;
19+
20+
/// A single-line JSON-RPC request padded out to at least `total` bytes.
21+
fn padded_request(id: u64, total: usize) -> String {
22+
let base = format!(r#"{{"jsonrpc":"2.0","id":{id},"method":"ping","params":{{"pad":""}}}}"#);
23+
let pad = total.saturating_sub(base.len());
24+
format!(
25+
r#"{{"jsonrpc":"2.0","id":{id},"method":"ping","params":{{"pad":"{}"}}}}"#,
26+
"x".repeat(pad)
27+
)
28+
}
29+
30+
fn small_request(id: u64) -> String {
31+
format!(r#"{{"jsonrpc":"2.0","id":{id},"method":"ping"}}"#)
32+
}
33+
34+
fn transport(max_line_length: usize) -> (DuplexStream, impl Transport<RoleServer>) {
35+
let (peer, ours) = tokio::io::duplex(64 * 1024);
36+
let transport = AsyncRwTransport::<RoleServer, _, _>::new(ours, tokio::io::sink())
37+
.with_max_line_length(max_line_length);
38+
(peer, transport)
39+
}
40+
41+
fn received_id(msg: &rmcp::service::RxJsonRpcMessage<RoleServer>) -> serde_json::Value {
42+
serde_json::to_value(msg).expect("received message is serializable")["id"].clone()
43+
}
44+
45+
/// A *valid* message over the limit must be dropped rather than delivered, and
46+
/// the stream must keep working afterwards.
47+
///
48+
/// Using a well-formed message matters: unparsable junk is discarded by the
49+
/// existing error handling either way, so only a valid oversized message
50+
/// distinguishes a bounded read from an unbounded one.
51+
#[tokio::test]
52+
async fn oversized_message_is_dropped_and_stream_recovers() {
53+
let (mut peer, mut transport) = transport(MAX);
54+
55+
tokio::spawn(async move {
56+
peer.write_all(padded_request(1, MAX * 4).as_bytes())
57+
.await
58+
.unwrap();
59+
peer.write_all(b"\n").await.unwrap();
60+
peer.write_all(small_request(2).as_bytes()).await.unwrap();
61+
peer.write_all(b"\n").await.unwrap();
62+
});
63+
64+
let msg = transport.receive().await.expect("second message delivered");
65+
assert_eq!(
66+
received_id(&msg),
67+
serde_json::json!(2),
68+
"the oversized message should have been dropped, not delivered"
69+
);
70+
}
71+
72+
/// The DoS shape from the report: bytes keep arriving with no newline at all.
73+
/// The transport must not accumulate them, and must recover once a delimiter
74+
/// finally shows up.
75+
#[tokio::test]
76+
async fn unterminated_flood_is_discarded_and_stream_recovers() {
77+
let (mut peer, mut transport) = transport(MAX);
78+
79+
tokio::spawn(async move {
80+
let chunk = vec![b'A'; 8 * 1024];
81+
// Well past the limit, still no newline.
82+
for _ in 0..16 {
83+
peer.write_all(&chunk).await.unwrap();
84+
}
85+
peer.write_all(b"\n").await.unwrap();
86+
peer.write_all(small_request(7).as_bytes()).await.unwrap();
87+
peer.write_all(b"\n").await.unwrap();
88+
});
89+
90+
let msg = transport
91+
.receive()
92+
.await
93+
.expect("message after the flood is delivered");
94+
assert_eq!(received_id(&msg), serde_json::json!(7));
95+
}
96+
97+
/// A message that fits must still be delivered, including right at the boundary.
98+
#[tokio::test]
99+
async fn message_within_the_limit_is_delivered() {
100+
let (mut peer, mut transport) = transport(MAX);
101+
102+
// `MAX` counts the trailing newline too, so this is the largest line that fits.
103+
let line = padded_request(3, MAX - 1);
104+
assert_eq!(line.len(), MAX - 1);
105+
106+
tokio::spawn(async move {
107+
peer.write_all(line.as_bytes()).await.unwrap();
108+
peer.write_all(b"\n").await.unwrap();
109+
});
110+
111+
let msg = transport.receive().await.expect("message delivered");
112+
assert_eq!(received_id(&msg), serde_json::json!(3));
113+
}
114+
115+
/// The default must be generous enough for real payloads, e.g. an embedded
116+
/// image, so existing callers are not broken by the bound being introduced.
117+
#[tokio::test]
118+
async fn default_limit_accepts_a_large_realistic_message() {
119+
let (peer, ours) = tokio::io::duplex(64 * 1024);
120+
let mut transport = AsyncRwTransport::<RoleServer, _, _>::new(ours, tokio::io::sink());
121+
let mut peer = peer;
122+
123+
assert_eq!(DEFAULT_MAX_LINE_LENGTH, 16 * 1024 * 1024);
124+
125+
tokio::spawn(async move {
126+
peer.write_all(padded_request(4, 1024 * 1024).as_bytes())
127+
.await
128+
.unwrap();
129+
peer.write_all(b"\n").await.unwrap();
130+
});
131+
132+
let msg = transport.receive().await.expect("1 MiB message delivered");
133+
assert_eq!(received_id(&msg), serde_json::json!(4));
134+
}
135+
136+
/// The bounded read replaced `read_until`, which used to be what carried a
137+
/// partially read line across cancellations. A line arriving in several chunks
138+
/// must still be reassembled.
139+
#[tokio::test]
140+
async fn line_split_across_many_reads_is_reassembled() {
141+
let (mut peer, mut transport) = transport(MAX);
142+
143+
let line = padded_request(5, MAX / 2);
144+
145+
tokio::spawn(async move {
146+
for chunk in line.as_bytes().chunks(97) {
147+
peer.write_all(chunk).await.unwrap();
148+
tokio::task::yield_now().await;
149+
}
150+
peer.write_all(b"\n").await.unwrap();
151+
});
152+
153+
let msg = transport.receive().await.expect("reassembled message");
154+
assert_eq!(received_id(&msg), serde_json::json!(5));
155+
}
156+
157+
/// Two oversized messages in a row must not wedge the transport.
158+
#[tokio::test]
159+
async fn consecutive_oversized_messages_still_recover() {
160+
let (mut peer, mut transport) = transport(MAX);
161+
162+
tokio::spawn(async move {
163+
for id in [1, 2] {
164+
peer.write_all(padded_request(id, MAX * 3).as_bytes())
165+
.await
166+
.unwrap();
167+
peer.write_all(b"\n").await.unwrap();
168+
}
169+
peer.write_all(small_request(9).as_bytes()).await.unwrap();
170+
peer.write_all(b"\n").await.unwrap();
171+
});
172+
173+
let msg = transport
174+
.receive()
175+
.await
176+
.expect("message after two oversized");
177+
assert_eq!(received_id(&msg), serde_json::json!(9));
178+
}
179+
180+
/// Negative control for the bound itself.
181+
///
182+
/// `usize::MAX` is the pre-fix behaviour, and with it the very same oversized
183+
/// message is delivered instead of dropped. This is what makes
184+
/// `oversized_message_is_dropped_and_stream_recovers` meaningful: the outcome
185+
/// changes only because of the limit.
186+
#[tokio::test]
187+
async fn unbounded_limit_still_delivers_an_oversized_message() {
188+
let (mut peer, mut transport) = transport(usize::MAX);
189+
190+
tokio::spawn(async move {
191+
peer.write_all(padded_request(1, MAX * 4).as_bytes())
192+
.await
193+
.unwrap();
194+
peer.write_all(b"\n").await.unwrap();
195+
peer.write_all(small_request(2).as_bytes()).await.unwrap();
196+
peer.write_all(b"\n").await.unwrap();
197+
});
198+
199+
let msg = transport.receive().await.expect("message delivered");
200+
assert_eq!(
201+
received_id(&msg),
202+
serde_json::json!(1),
203+
"with no bound the oversized message is buffered and delivered, which is the behaviour the bound removes"
204+
);
205+
}

0 commit comments

Comments
 (0)