Dev.to AI πŸ€– Ai πŸ‘ 0 πŸ“– 9 min read

CVE-2026-42559: DNS Rebinding in rmcp's Streamable HTTP Server Transport

Overview Field Value CVE ID CVE-2026-42559 Component rmcp (official Rust SDK for the Model Context Protocol) Vulnerable location crates/rmcp/src/transport/streamable_http_server/tower.rs Affected versions

CVE-2026-42559: DNS Rebinding in rmcp's Streamable HTTP Server Transport

Overview

Field Value
CVE ID CVE-2026-42559
Component rmcp (official Rust SDK for the Model Context Protocol)
Vulnerable location crates/rmcp/src/transport/streamable_http_server/tower.rs
Affected versions rmcp < 1.4.0
Patched version rmcp >= 1.4.0 (commit 8e22aa2, PR #764)
CVSS 3.1 8.8 (High) β€” AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CWE CWE-346 (Origin Validation Error), CWE-350 (Reliance on Reverse DNS Resolution)
Vulnerability class DNS rebinding β†’ local service access

rmcp is Anthropic's official Rust SDK for the Model Context Protocol (MCP), implementing the transport layer between LLM clients and the MCP servers that expose tools to them. Servers built with this SDK almost always run on a developer's own machine (127.0.0.1), exposing powerful tools β€” filesystem access, shell execution, browser control β€” over that loopback interface. The problem: prior to 1.4.0, the Streamable HTTP transport never validated the incoming Host header at all. That single missing check, combined with a DNS rebinding attack, is enough for a malicious web page to invoke arbitrary tools on a locally running MCP server the moment a victim opens it in a browser.

How DNS rebinding works here

A browser's Same-Origin Policy (SOP) decides trust based on a domain name, while the actual destination of a request is whatever IP DNS happens to resolve that name to at that moment. DNS rebinding attacks exploit exactly this gap.

  1. The attacker sets a very short DNS TTL (e.g. 1 second) on a domain they control (attacker.example).
  2. When the victim visits it, the first DNS answer is a legitimate public IP, so the page loads normally.
  3. Once the TTL expires, in-page JavaScript re-requests the same domain β€” and this time the attacker's DNS answers with 127.0.0.1 (or another private address).
  4. From the browser's perspective it's still the "same origin" (attacker.example), so SOP is satisfied, but the TCP connection now actually goes to the victim's own machine.
  5. The HTTP request still carries Host: attacker.example, but the server receiving it is the local MCP server started via rmcp.

If the server had simply checked the Host header against a list of names it recognizes (localhost, 127.0.0.1, etc.) and rejected anything else, the attack would be stopped right at step 5. Prior to the patch, rmcp had no such check at all.

Before the patch: what was missing

Per the GitHub security advisory (GHSA-89vp-x53w-74fx), pre-1.4.0 versions of the Streamable HTTP server went straight from protocol-version checks (MCP-Protocol-Version), session-ID lookup, and JSON-RPC parsing into request handling β€” there was no step anywhere in the pipeline that compared Host/Origin against anything. Conceptually:

// Pre-patch (conceptual reconstruction) β€” no Host validation step existed
pub async fn handle<B>(&self, request: Request<B>) -> Response<BoxBody<Bytes, Infallible>>
where
    B: Body + Send + 'static,
    B::Error: Display,
{
    // <-- nothing here checked Host / Origin
    let method = request.method().clone();
    let result = match method {
        Method::POST => self.handle_post(request).await,
        Method::GET  => self.handle_get(request).await,
        Method::DELETE => self.handle_delete(request).await,
        _ => /* 405 */,
    };
    // ...
}

The server only asked "does this conform to the MCP protocol?", never "who is this request actually from?" It implicitly trusted any request that reached the loopback socket β€” and DNS rebinding is precisely the technique that breaks the assumption that "arrived on loopback" and "the Host header names the real caller" are the same thing.

Per the advisory, the blast radius is broad:

  • Enumerate (tools/list) and invoke (tools/call) every tool the server exposes
  • Read resources, prompts, and any state reachable through the session
  • Trigger side effects the tools support: file writes, shell execution, arbitrary outbound API calls

Because MCP servers typically run with the user's own privileges and often expose developer tooling (filesystem, shell, browser automation, language servers), the practical impact can extend to full code execution on the victim's machine.

After the patch: reading the actual source

The fix has three parts: β‘  a secure-by-default allowlist for hostnames, β‘‘ a validate_dns_rebinding_headers gate that every request now passes through, and β‘’ optional Origin validation as defense-in-depth. Let's walk through the deployed tower.rs source.

1) A secure default: StreamableHttpServerConfig

pub struct StreamableHttpServerConfig {
    // ... session/SSE fields omitted

    /// Allowed hostnames or `host:port` authorities for inbound `Host` validation.
    ///
    /// By default, Streamable HTTP servers only accept loopback hosts to
    /// prevent DNS rebinding attacks against locally running servers.
    pub allowed_hosts: Vec<String>,

    /// Allowed browser origins for inbound `Origin` validation.
    pub allowed_origins: Vec<String>,
    validate_empty_origin_allowlist: bool,
    // ...
}

impl Default for StreamableHttpServerConfig {
    fn default() -> Self {
        Self {
            // ...
            allowed_hosts: vec!["localhost".into(), "127.0.0.1".into(), "::1".into()],
            allowed_origins: vec![],
            validate_empty_origin_allowlist: false,
            // ...
        }
    }
}

The Default impl is the load-bearing part. Just calling StreamableHttpServerConfig::default() β€” no extra configuration needed β€” populates allowed_hosts with localhost, 127.0.0.1, and ::1. That flip from "insecure by default" to "secure by default" is the design philosophy behind this patch. A developer deploying publicly has to explicitly widen it via with_allowed_hosts(["mcp.example.com"]), and turning validation off entirely requires an equally explicit disable_allowed_hosts() β€” nothing about the safe path is accidental anymore.

impl StreamableHttpServerConfig {
    pub fn with_allowed_hosts(
        mut self,
        allowed_hosts: impl IntoIterator<Item = impl Into<String>>,
    ) -> Self {
        self.allowed_hosts = allowed_hosts.into_iter().map(Into::into).collect();
        self
    }

    /// Disable allowed hosts. NOT recommended for public deployments.
    pub fn disable_allowed_hosts(mut self) -> Self {
        self.allowed_hosts.clear();
        self
    }
}

Note that an empty allowed_hosts vector is exactly what host_is_allowed (below) treats as "allow everything," so disable_allowed_hosts() is a literal opt-out of this whole protection.

2) Enforcement at the entry point

pub async fn handle<B>(&self, request: Request<B>) -> Response<BoxBody<Bytes, Infallible>>
where
    B: Body + Send + 'static,
    B::Error: Display,
{
    if let Err(response) =
        validate_dns_rebinding_headers(request.uri(), request.headers(), &self.config)
    {
        return response.into_response();
    }
    // routing to POST/GET/DELETE only happens after this
    ...
}

Placement is the key detail here: validate_dns_rebinding_headers runs at the very top of handle(), ahead of both method dispatch and session lookup. A failed check returns immediately, before the server ever touches session state or parses the JSON-RPC body β€” shrinking the attack surface to "before the request reaches any internal logic at all."

3) Parsing the Host header β€” parse_host_header

fn parse_host_header(uri: &http::Uri, headers: &HeaderMap) -> HttpResult<NormalizedAuthority> {
    if let Some(host) = headers.get(http::header::HOST) {
        let host_str = host
            .to_str()
            .map_err(|_| bad_request_response("Bad Request: Invalid Host header encoding"))?;
        let authority = http::uri::Authority::try_from(host_str)
            .map_err(|_| bad_request_response("Bad Request: Invalid Host header"))?;
        return Ok(normalize_authority(authority.host(), authority.port_u16()));
    }
    // HTTP/2 carries the target in the :authority pseudo-header instead.
    let authority = uri.authority().ok_or_else(|| {
        bad_request_response("Bad Request: missing Host header")
    })?;
    Ok(normalize_authority(authority.host(), authority.port_u16()))
}

The HTTP/2 fallback matters. HTTP/1.1 names the target via the Host: header, but HTTP/2 replaces it with the :authority pseudo-header on the request line, and some middleware (e.g. axum::Router::nest) can strip the Host header hyper synthesizes from it during routing. So instead of "no Host header β‡’ allow," the code falls back to the URI's authority β€” meaning a missing header still gets checked against something, never silently skipped.

4) Normalizing the host β€” case and IPv6 brackets

fn normalize_host(host: &str) -> String {
    host.trim_matches('[')
        .trim_matches(']')
        .to_ascii_lowercase()
}

struct NormalizedAuthority {
    host: String,
    port: Option<u16>,
}

fn normalize_authority(host: &str, port: Option<u16>) -> NormalizedAuthority {
    NormalizedAuthority { host: normalize_host(host), port }
}

DNS names are case-insensitive (Localhost and localhost are the same host), and IPv6 literals appear bracketed in a URI ([::1]). Without normalizing both, an attacker could try bypassing the allowlist comparison with variants like Host: LOCALHOST or Host: [::1]. normalize_host handles both in one place so every later comparison can be a plain string ==.

5) Allowlist matching β€” host_is_allowed

fn parse_allowed_authority(allowed: &str) -> Option<NormalizedAuthority> {
    let allowed = allowed.trim();
    if allowed.is_empty() {
        return None;
    }
    if let Ok(authority) = http::uri::Authority::try_from(allowed) {
        return Some(normalize_authority(authority.host(), authority.port_u16()));
    }
    Some(normalize_authority(allowed, None))
}

fn host_is_allowed(host: &NormalizedAuthority, allowed_hosts: &[String]) -> bool {
    if allowed_hosts.is_empty() {
        // Empty allowlist = allow everything (not recommended).
        return true;
    }
    allowed_hosts
        .iter()
        .filter_map(|allowed| parse_allowed_authority(allowed))
        .any(|allowed| {
            allowed.host == host.host
                && match allowed.port {
                    Some(port) => host.port == Some(port),
                    None => true,
                }
        })
}

Each allowlist entry ("example.com", "example.com:8080", etc.) is parsed into a host/port pair before comparison. An entry with no port ("localhost") matches that host on any port; an entry with a port ("example.com:8080") only matches that exact port. This lets a developer keep localhost wide open (any port) during development while locking a production deployment down to example.com:443.

6) The gate itself β€” validate_dns_rebinding_headers

fn validate_dns_rebinding_headers(
    uri: &http::Uri,
    headers: &HeaderMap,
    config: &StreamableHttpServerConfig,
) -> HttpResult<()> {
    let host = parse_host_header(uri, headers)?;
    if !host_is_allowed(&host, &config.allowed_hosts) {
        tracing::warn!(
            host = ?host,
            "rejected request with disallowed Host header (possible DNS rebinding attempt)",
        );
        return Err(forbidden_response("Forbidden: Host header is not allowed").into());
    }
    validate_origin_header(headers, config)?;
    Ok(())
}

This single function is the fix. It parses Host, and if it isn't on the allowlist it returns 403 Forbidden immediately, logging a tracing::warn! that flags the possibility of a DNS rebinding attempt. In the diagram above, step 5's request arriving with Host: attacker.example is stopped right here β€” attacker.example isn't in the default allowlist (localhost, 127.0.0.1, ::1).

7) Origin validation β€” defense-in-depth

fn validate_origin_header(
    headers: &HeaderMap,
    config: &StreamableHttpServerConfig,
) -> HttpResult<()> {
    if !config.validate_empty_origin_allowlist && config.allowed_origins.is_empty() {
        return Ok(());
    }
    let Some(origin_header) = headers.get(http::header::ORIGIN) else {
        return Ok(());
    };
    let origin_str = origin_header
        .to_str()
        .map_err(|_| forbidden_response("Forbidden: Invalid Origin header encoding"))?;
    let origin = parse_origin_value(origin_str)
        .ok_or_else(|| forbidden_response("Forbidden: Invalid Origin header"))?;
    if !origin_is_allowed(&origin, &config.allowed_origins) {
        tracing::warn!(origin = ?origin, "rejected request with disallowed Origin header");
        return Err(forbidden_response("Forbidden: Origin header is not allowed").into());
    }
    Ok(())
}

As the advisory states explicitly, the root cause here is purely the missing Host check β€” Origin validation isn't required to stop this specific attack, since a browser can't forge the Host header it sends to the rebound target; the Host allowlist alone is sufficient. Origin checking was added anyway as defense-in-depth: by default, an empty allowed_origins list skips validation entirely (preserving backward compatibility), while calling enforce_origin_validation() switches to a strict mode that rejects any request carrying an Origin header, even with an empty allowlist.

Remediation

  • Upgrade to rmcp 1.4.0 or later. The default configuration alone already blocks any request whose Host isn't a loopback address.
  • If you're deploying an MCP server under a public domain, register it explicitly: StreamableHttpServerConfig::default().with_allowed_hosts(["mcp.example.com"]).
  • If upgrading isn't immediately possible, put a reverse proxy (nginx, Caddy, etc.) in front that rejects requests with an unexpected Host header, and don't bind the MCP server directly to 0.0.0.0.
  • Avoid disable_allowed_hosts() β€” it reverts you to the pre-patch, unprotected state β€” unless an upstream proxy is already validating Host on your behalf.
πŸ“° Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.