GHSA-rjr6-rcgv-9m7m MCP Ruby SDK: Streamable HTTP transport lacks DNS-rebinding (Host/Origin) protection
Summary`MCP::Server::Transports::StreamableHTTPTransport` (the Rack-mountable Streamable HTTP transport in the `mcp` gem) processes every incoming JSON-RPC request without ever inspecting the HTTP `Host` or `Origin` request headers. There is no `AllowedHosts`/`AllowedOrigins` allowlist and no DNS-rebinding guard anywhere in the transport. A local MCP server that binds a loopback or LAN HTTP port is therefore reachable by any web origin a victim's browser visits, via a DNS-rebinding attack: a malicious page rebinds its own hostname to `127.0.0.1`, then drives the local MCP server cross-origin to enumerate and invoke its tools and exfiltrate their output. This is the standard browser-driven local-service attack that the MCP Streamable HTTP guidance exists to prevent.ImpactAn attacker who can get a victim to open a web page can reach any MCP server the victim runs locally over the Streamable HTTP transport (e.g. a developer-tools or filesystem MCP server on `localhost`).Because the transport issues a session and dispatches `tools/list` / `tools/call` from a foreign `Host`/`Origin` with no rejection, the attacker can drive arbitrary server-exposed tools and read their results, exfiltrating local data (files, secrets, command output) to the attacker's origin.The blast radius is whatever the locally-running MCP server exposes. For MCP servers wired to filesystem, shell, or credential tools, this is sensitive-data disclosure and, depending on the tool set, local action execution.Vulnerable codeFile: `lib/mcp/server/transports/streamable_http_transport.rb` (gem `mcp` 0.18.0).The Rack entrypoint and POST handler validate `Accept`, `Content-Type`, `Mcp-Session-Id`, and `Mcp-Protocol-Version`, but never `Host` or `Origin`:No statement anywhere in `handle_post`, `handle_request`, or any helper reads `request.env["HTTP_HOST"]` or `request.env["HTTP_ORIGIN"]`.The only request-env reads in the whole class are:`extract_session_id` -> `request.env["HTTP_MCP_SESSION_ID"]` (line 489)`validate_accept_header` -> `request.env["HTTP_ACCEPT"]` (line 493)`validate_content_type` -> `request.env["CONTENT_TYPE"]` (line 512)`validate_protocol_version_header` -> `request.env["HTTP_MCP_PROTOCOL_VERSION"]` (line 546)A repository-wide search of `lib/` for `HTTP_HOST`, `HTTP_ORIGIN`, `allowed_host`, `allowed_origin`, `rebind`, or `dns.rebind` returns zero matches, confirming no allowlist or rebinding guard exists in the shipped library. The `examples/` tree mounts `Rack::Cors` as application-level middleware, but that is example glue, not a transport-level control, and CORS does not stop a DNS-rebinding attack that arrives as a same-origin request after rebinding.How the input reaches the sink (attack scenario)A developer runs an MCP server over `StreamableHTTPTransport`, mounted as a Rack app on a local HTTP port (loopback or LAN).The victim opens `http://evil.attacker.com` in a browser. The page resolves to the attacker's server, which then re-answers DNS for `evil.attacker.com` with `127.0.0.1` (DNS rebinding). The browser now treats requests to `evil.attacker.com` as going to the local MCP server, with `Host: evil.attacker.com` / `Origin: http://evil.attacker.com`.The page POSTs an `initialize` request. The transport accepts it (it never looks at `Host`/`Origin`), creates a session, and returns `Mcp-Session-Id`.The page then POSTs `tools/call`, and the transport executes the server's tool and returns its output to the foreign origin. Local data is exfiltrated.Proof of concept (end-to-end reproduction)Run against the real released gem `mcp` 0.18.0 (no stubs). The script builds an `MCP::Server` with a tool that returns sensitive local data, instantiates the real `StreamableHTTPTransport`, and drives it with `Rack::Request` env hashes carrying a forged `Host`/`Origin`. It then re-runs as a legitimate localhost client (negative control).Install:PoC (`poc_f1_dnsrebind.rb`):Captured output (verbatim):The forged `Host: attacker.evil.com` / `Origin: http://evil.attacker.com` request obtained a valid session and exfiltrated the local secret (`TOP-SECRET-LOCAL-DATA-9f3a`) via `tools/call`, with the transport returning HTTP 200 throughout and never a 403. The negative control confirms the transport applies the identical (empty) policy to a legitimate localhost client, proving there is no Host/Origin discrimination at all.Suggested fixAdd an opt-in but secure-by-default Host/Origin allowlist to `StreamableHTTPTransport`, mirroring the DNS-rebinding protection that the TypeScript, Python, Go, Rust, C#, and Java MCP SDKs already ship:Accept `allowed_hosts:` and `allowed_origins:` keyword arguments in `initialize`.In `handle_request` (before any dispatch), read `request.env["HTTP_HOST"]` and `request.env["HTTP_ORIGIN"]`. If an allowlist is configured and the value is not on it, return `403 Forbidden`.Default to allowing only loopback hosts (`127.0.0.1`, `[::1]`, `localhost`) and an empty/absent `Origin`, so a stock local deployment is protected against rebinding out of the box while same-process and same-host clients keep working. Document how to widen the allowlist for non-loopback deployments.A concrete patch adds an `AllowedHostsValidation` check invoked at the top of `handle_request`. See the Fix PR.Fix PRA fix PR implementing the Host/Origin allowlist with a secure loopback default is open against this advisory's private temporary fork: https://github.com/modelcontextprotocol/ruby-sdk-ghsa-rjr6-rcgv-9m7m/pull/1 . With the patch loaded, the forged-Host request is rejected with `403` (`{"error":"Forbidden: Host not allowed (DNS-rebinding protection)"}`) while a legitimate loopback `Host: 127.0.0.1:8080` request is still served (HTTP 200, session issued).CreditReported by tonghuaroot.Reporter notesThis issue was found by source review of the `mcp` gem's Streamable HTTP transport and confirmed end-to-end against the released gem `mcp` 0.18.0 as shown above. It is reported independently on its own merits.
CVSS v4.0 score: 6.9