diff --git a/README.md b/README.md index b986c26..bdee5e3 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,27 @@ The following examples show two common integration styles in Rails. > > Stateless mode (`stateless: true`) does not use sessions and works with any server configuration. +> [!IMPORTANT] +> Per MCP 2025-11-25, `StreamableHTTPTransport` validates the `Host` and `Origin` headers by default to +> prevent DNS rebinding attacks against locally bound servers, rejecting unauthorized values with HTTP 403. +> `Host` is allowed for the loopback defaults (`127.0.0.1`, `::1`, `localhost`), and an `Origin` header, +> when present, must be same-origin or explicitly allow-listed. Non-browser clients that send no `Origin` +> header are unaffected. +> +> Deployments behind a reverse proxy or bound to a non-loopback interface must widen the allow lists: +> +> ```ruby +> transport = MCP::Server::Transports::StreamableHTTPTransport.new( +> server, +> allowed_hosts: ["mcp.example.com"], +> allowed_origins: ["https://app.example.com"], +> ) +> ``` +> +> An `allowed_hosts:` entry matches either the bare host name (any port) or the full `host:port` value, +> so both `"mcp.example.com"` and `"mcp.example.com:8443"` work. Pass `dns_rebinding_protection: false` +> to disable the check entirely (e.g., when an upstream proxy or middleware already validates `Host`/`Origin`). + ##### Rails (mount) `StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes: diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 4b8f8ad..8b8e2f9 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -24,7 +24,31 @@ module MCP "Connection" => "keep-alive", }.freeze - def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil) + # Creates a Streamable HTTP transport that can be mounted as a Rack app. + # + # @param server [MCP::Server] the server whose requests this transport dispatches. + # @param stateless [Boolean] when `true`, no session is issued and each POST is self-contained. + # @param enable_json_response [Boolean] when `true`, a request is answered with a single JSON + # object instead of an SSE stream. + # @param session_idle_timeout [Numeric, nil] seconds before an idle session is reaped; `nil` + # disables expiry. Not supported in stateless mode. + # @param allowed_origins [Array, nil] extra `Origin` values accepted in addition to + # same-origin requests, for DNS rebinding protection. + # @param allowed_hosts [Array, nil] extra `Host` values accepted beyond the loopback + # defaults (`127.0.0.1`, `::1`, `localhost`); each entry matches a bare host name (any port) + # or a full `host:port`. + # @param dns_rebinding_protection [Boolean] when `true` (default), validates the `Host` and + # `Origin` headers to prevent DNS rebinding; pass `false` when an upstream proxy already + # validates them. + def initialize( + server, + stateless: false, + enable_json_response: false, + session_idle_timeout: nil, + allowed_origins: nil, + allowed_hosts: nil, + dns_rebinding_protection: true + ) super(server) # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`. @sessions = {} @@ -33,6 +57,11 @@ module MCP @stateless = stateless @enable_json_response = enable_json_response @session_idle_timeout = session_idle_timeout + @dns_rebinding_protection = dns_rebinding_protection + + # Host names are case-insensitive, so the allow lists are compared down-cased. + @allowed_hosts = (DEFAULT_LOOPBACK_HOSTS + Array(allowed_hosts)).map(&:downcase).freeze + @allowed_origins = Array(allowed_origins).map(&:downcase).freeze @pending_responses = {} if @session_idle_timeout @@ -52,12 +81,19 @@ module MCP STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze SESSION_REAP_INTERVAL = 60 + # Loopback hosts always accepted by DNS rebinding protection. A locally bound MCP server (the canonical pattern) is + # protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`. + DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze + # Rack app interface. This transport can be mounted as a Rack app. def call(env) handle_request(Rack::Request.new(env)) end def handle_request(request) + rebinding_error = validate_dns_rebinding(request) + return rebinding_error if rebinding_error + case request.env["REQUEST_METHOD"] when "POST" handle_post(request) @@ -808,6 +844,74 @@ module MCP active end + # Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost + # to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default; + # pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already + # performs the check). The `Host` header is validated against the loopback defaults plus `allowed_hosts:`, + # and the `Origin` header, when present, must be same-origin or in `allowed_origins:`. + def validate_dns_rebinding(request) + return unless @dns_rebinding_protection + + validate_host(request) || validate_origin(request) + end + + # Rejects a rebound `Host` (e.g. `evil.example.com` re-pointed at 127.0.0.1). + # A request without a `Host` header (e.g. HTTP/1.0) is allowed; the rebinding vector this guards against always carries one. + def validate_host(request) + host = request.env["HTTP_HOST"] + return if host.nil? + + # An `allowed_hosts:` entry matches either the bare host name (any port) + # or the full `host:port` value, so both `"app.example.com"` and + # `"app.example.com:8443"` can be configured. + normalized = host.downcase + return if @allowed_hosts.include?(request_hostname(normalized)) || @allowed_hosts.include?(normalized) + + forbidden_response("Forbidden: Invalid Host header") + end + + # A request without an `Origin` header (typical for non-browser MCP clients) is allowed. A browser cross-origin request is + # rejected unless the origin is same-origin or explicitly allow-listed via `allowed_origins:`. + def validate_origin(request) + origin = request.env["HTTP_ORIGIN"] + return if origin.nil? + return if same_origin?(origin, request) + return if @allowed_origins.include?(origin.downcase) + + forbidden_response("Forbidden: Invalid Origin header") + end + + # Extracts the host name from a `Host` header value, stripping any port and IPv6 brackets + # (`[::1]:8080` becomes `::1`, `127.0.0.1:8080` becomes `127.0.0.1`). + def request_hostname(host) + return host[/\A\[([^\]]+)\]/, 1] if host.start_with?("[") + + host.split(":").first + end + + # Compares the `Origin` authority (host:port) against the request's own `Host`. + # Scheme is not compared (the `Host` header carries none, and `request.scheme` is unreliable behind proxies), + # but the `Origin`'s scheme is used to drop a redundant default port (`:80` for http, `:443` for https) from + # both sides so `http://example.com` matches `Host: example.com:80`. Comparison is case-insensitive. + def same_origin?(origin, request) + host = request.env["HTTP_HOST"] + return false if host.nil? + + normalized = origin.downcase + default_port = normalized.start_with?("https://") ? ":443" : ":80" + authority = normalized.sub(%r{\Ahttps?://}, "") + + authority.delete_suffix(default_port) == host.downcase.delete_suffix(default_port) + end + + def forbidden_response(message) + json_rpc_error_response( + status: 403, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: message, + ) + end + def method_not_allowed_response json_rpc_error_response( status: 405, diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 550e926..fe57579 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -4328,6 +4328,306 @@ module MCP refute headers.frozen?, "SSE response headers should not be frozen" end + test "handle_request returns 403 for POST when Origin is not in allowed_origins" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ORIGIN" => "https://evil.example.com", + }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + + status, headers, body = transport.handle_request(request) + assert_equal(403, status) + assert_equal("application/json", headers["Content-Type"]) + parsed = JSON.parse(body.first) + assert_equal(JsonRpcHandler::ErrorCode::INVALID_REQUEST, parsed["error"]["code"]) + assert_equal("Forbidden: Invalid Origin header", parsed["error"]["message"]) + ensure + transport&.close + end + + test "handle_request returns 403 for GET when Origin is not in allowed_origins" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "GET", + "/", + { "HTTP_ORIGIN" => "https://evil.example.com" }, + ) + + status, = transport.handle_request(request) + assert_equal(403, status) + ensure + transport&.close + end + + test "handle_request returns 403 for DELETE when Origin is not in allowed_origins" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "DELETE", + "/", + { + "HTTP_ORIGIN" => "https://evil.example.com", + "HTTP_MCP_SESSION_ID" => "any", + }, + ) + + status, = transport.handle_request(request) + assert_equal(403, status) + ensure + transport&.close + end + + test "handle_request allows POST when Origin is in allowed_origins" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ORIGIN" => "https://app.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status) + ensure + transport&.close + end + + test "handle_request allows POST when Origin header is absent (e.g. non-browser client)" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"]) + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status) + ensure + transport&.close + end + + test "handle_request rejects a cross-origin request by default (secure-by-default)" do + # No allowed_origins configured: a browser cross-origin Origin is still rejected. + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ORIGIN" => "https://anywhere.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = @transport.handle_request(request) + assert_equal 403, status + end + + test "handle_request allows a same-origin browser request" do + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "localhost:3000", + "HTTP_ORIGIN" => "http://localhost:3000", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = @transport.handle_request(request) + assert_equal 200, status + end + + test "handle_request rejects a rebound Host by default" do + # DNS rebinding: a foreign Host re-pointed at the loopback server is rejected. + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "evil.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, headers, body = @transport.handle_request(request) + assert_equal(403, status) + assert_equal("application/json", headers["Content-Type"]) + parsed = JSON.parse(body.first) + assert_equal(JsonRpcHandler::ErrorCode::INVALID_REQUEST, parsed["error"]["code"]) + assert_equal("Forbidden: Invalid Host header", parsed["error"]["message"]) + end + + test "handle_request allows loopback Host values by default" do + ["127.0.0.1:8080", "localhost:3000", "[::1]:8080"].each do |host| + transport = StreamableHTTPTransport.new(@server) + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => host, + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status, "expected #{host} to be allowed") + ensure + transport.close + end + end + + test "handle_request matches Host case-insensitively" do + # Host names are case-insensitive, so an upper-cased loopback Host is allowed. + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "LOCALHOST:3000", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = @transport.handle_request(request) + assert_equal(200, status) + end + + test "handle_request allows a non-loopback Host listed in allowed_hosts" do + transport = StreamableHTTPTransport.new(@server, allowed_hosts: ["app.example.com"]) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "app.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status) + ensure + transport.close + end + + test "handle_request matches a host:port entry in allowed_hosts exactly" do + transport = StreamableHTTPTransport.new(@server, allowed_hosts: ["app.example.com:8443"]) + + allowed = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "app.example.com:8443" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + rejected = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "app.example.com:9000" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + assert_equal(200, transport.handle_request(allowed).first) + assert_equal(403, transport.handle_request(rejected).first) + ensure + transport.close + end + + test "handle_request treats a default port as same-origin" do + # `Origin: http://localhost` (no port) is same-origin with `Host: localhost:80`. + [ + ["http://localhost", "localhost:80"], + ["https://localhost", "localhost:443"], + ].each do |origin, host| + transport = StreamableHTTPTransport.new(@server) + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => host, "HTTP_ORIGIN" => origin }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + assert_equal(200, transport.handle_request(request).first, "expected #{origin} / #{host} to match") + ensure + transport.close + end + end + + test "handle_request rejects opaque and malformed Origins" do + ["null", "file:///etc/passwd", "http://localhost:3000/path"].each do |origin| + transport = StreamableHTTPTransport.new(@server) + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "localhost:3000", "HTTP_ORIGIN" => origin }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + assert_equal(403, transport.handle_request(request).first, "expected #{origin} to be rejected") + ensure + transport.close + end + end + + test "handle_request enforces Host validation on GET requests" do + request = create_rack_request( + "GET", + "/", + { "HTTP_HOST" => "evil.example.com" }, + ) + + status, = @transport.handle_request(request) + assert_equal(403, status) + end + + test "handle_request allows a request with no Host header" do + # Non-browser clients may omit Host; the rebinding vector always carries one. + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = @transport.handle_request(request) + assert_equal(200, status) + end + + test "handle_request skips DNS rebinding checks when protection is disabled" do + transport = StreamableHTTPTransport.new(@server, dns_rebinding_protection: false) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "evil.example.com", + "HTTP_ORIGIN" => "https://evil.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status) + ensure + transport.close + end + private def create_rack_request(method, path, headers, body = nil)