From 7f1147a4d18f1ccf2efb493a9ff436058f1ad340 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 14 Nov 2026 23:24:19 +0900 Subject: [PATCH] Accept every `Origin` with `allowed_origins: :any` and keep the `Host` check ## Motivation and Context `StreamableHTTPTransport` accepts a cross-origin request only when its `Origin` is listed in `allowed_origins:`. A remote server that browser-based clients on any origin may call, authenticated by a bearer token alone, had two ways to admit them: `dns_rebinding_protection: false`, which also turns off the `Host` check, or building the transport per request with that request's own `Origin` in the list. `allowed_origins: :any` now skips the `Origin` check alone. A rebound request names the attacker's host in `Host` and, if sent, `Origin`, so it passes as same-origin and the `Host` check is what refuses it; that check stays on. What the `Origin` check adds is refusing a page on another site that relies on what the browser attaches by itself (a cookie, or the reach of a server on localhost), which a server requiring a bearer token on every request does not accept. The docs state that condition. The TypeScript SDK arms its Host and Origin validation separately, so the same setup is already possible there. Resolves #684. ## How Has This Been Tested? `test/mcp/server/transports/streamable_http_transport_test.rb`; the test that sends three foreign `Origin` values with `allowed_origins: :any` gets 403 on the previous library. ## Breaking Changes A Symbol passed as `allowed_origins:` was accepted before and matched no `Origin`. `:any` now turns the `Origin` check off, and another Symbol raises `ArgumentError` at construction. --- docs/_server/authorization.md | 3 + docs/_server/transports.md | 20 +++++ .../transports/streamable_http_transport.rb | 25 ++++++- .../streamable_http_transport_test.rb | 74 +++++++++++++++++++ 4 files changed, 118 insertions(+), 4 deletions(-) diff --git a/docs/_server/authorization.md b/docs/_server/authorization.md index d33cd6cc..cf877e09 100644 --- a/docs/_server/authorization.md +++ b/docs/_server/authorization.md @@ -83,6 +83,9 @@ For composition at the Rack layer instead (e.g. sharing one authenticator across When a browser-based client is involved, run the CORS middleware before bearer enforcement so preflight `OPTIONS` requests are not answered with 401, and expose the `WWW-Authenticate` response header, or the browser withholds the challenge that points the client at the metadata document. +CORS headers alone do not admit a client on another origin: its requests must also pass the transport's `Origin` check, +by listing the origin in `allowed_origins:` or, when clients on any origin may call the server, with `allowed_origins: :any`; +see [Streamable HTTP Transport](/server/transports/#streamable-http-transport). `Mcp-Session-Id` in the snippet below is unrelated to authorization: a handshake-lifecycle client running in a browser cannot keep its session without it, while the modern lifecycle carries no session. `ProtectedResourceMetadataMiddleware` answers preflights and sets `Access-Control-Allow-Origin: *` on its own, since the document is meant to be fetched cross-origin: diff --git a/docs/_server/transports.md b/docs/_server/transports.md index 9bbcc985..869cd9c5 100644 --- a/docs/_server/transports.md +++ b/docs/_server/transports.md @@ -104,6 +104,26 @@ The following examples show two common integration styles in Rails. > to disable the check entirely (e.g., when an upstream proxy or middleware already validates `Host`/`Origin`). > The check runs before any lifecycle dispatch, so it protects requests of > the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) as well. +> +> A server that browser-based clients on any origin may call passes `allowed_origins: :any`, +> which turns `Origin` validation off while `Host` validation stays on: +> +> ```ruby +> transport = MCP::Server::Transports::StreamableHTTPTransport.new( +> server, +> allowed_hosts: ["mcp.example.com"], +> allowed_origins: :any, +> token_verifier: verifier, # every request must carry a bearer token +> ) +> ``` +> +> Use it only when every request must carry a credential the browser does not attach on its own, +> such as a bearer token in the `Authorization` header. A cookie, HTTP Basic authentication and a client certificate +> are attached by the browser, and a server without authentication trusts whoever reaches it on localhost or an internal network: +> with any of those, a page on any site could send requests with the user's authority. +> The transport sends no CORS response headers and answers a preflight `OPTIONS` with 405, +> so browser-based clients also need a CORS middleware such as `rack-cors` in front of it; +> [Authorization](/server/authorization/) shows a setup. ### Rails (mount) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index e3b7a1b4..12d09f99 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -145,8 +145,10 @@ class InvalidJsonError < StandardError; end # expiry. Not supported in stateless mode. # @param max_sessions [Integer, nil] cap on the concurrent session count in stateful mode; a new # `initialize` past the cap is rejected with HTTP 503, and `nil` disables the cap. - # @param allowed_origins [Array, nil] extra `Origin` values accepted in addition to - # same-origin requests, for DNS rebinding protection. + # @param allowed_origins [Array, :any, nil] extra `Origin` values accepted in addition to + # same-origin requests, for DNS rebinding protection; `:any` accepts every `Origin` while + # the `Host` check stays on, for a server whose every request carries a credential the browser + # does not attach on its own (a bearer token, not a cookie). # @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`. @@ -253,9 +255,17 @@ def initialize( @session_request_validator = session_request_validator @dns_rebinding_protection = dns_rebinding_protection + # A Symbol never equals an `Origin` value, so one other than `:any` would refuse every cross-origin request + # without a hint that it was mistyped. + if allowed_origins.is_a?(Symbol) && allowed_origins != :any + raise ArgumentError, "allowed_origins must be an Array of Origin values or :any (got #{allowed_origins.inspect}); " \ + ":any turns Origin validation off, for a server that requires a bearer token on every request" + end + # 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 + @any_origin_allowed = allowed_origins == :any + @allowed_origins = (@any_origin_allowed ? [] : Array(allowed_origins)).map(&:downcase).freeze @pending_responses = {} # Maps a key the transport mints for each `subscriptions/listen` stream to @@ -2570,7 +2580,7 @@ def authenticate_request(request) # 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:`. + # and the `Origin` header, when present, must be same-origin or in `allowed_origins:`, unless that is `:any`. def validate_dns_rebinding(request) return unless @dns_rebinding_protection @@ -2602,7 +2612,14 @@ def validate_host(request) # 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:`. + # + # `allowed_origins: :any` skips this check alone. A rebound request names the attacker's host in `Host` and, when it carries one, + # in `Origin`, so it is same-origin here and `validate_host` is what refuses it; what this check adds is refusing a page on another site + # that relies on what the browser attaches by itself, which a server requiring a bearer token does not accept. + # The `Origin` a session was created with still binds its later requests (`session_request_rejection`). def validate_origin(request) + return if @any_origin_allowed + origin = header_bytes(request, "HTTP_ORIGIN") return if origin.nil? return if same_origin?(origin, request) diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index c5cb6c9a..d64b4d23 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -5961,6 +5961,55 @@ def string assert_equal 403, status end + test "handle_request allows every Origin with allowed_origins: :any" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: :any) + + ["https://anywhere.example.com", "null", "chrome-extension://abcdefgh"].each do |origin| + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => origin }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + status, = transport.handle_request(request) + assert_equal(200, status, origin) + end + ensure + transport&.close + end + + test "rejects a Symbol other than :any for allowed_origins" do + error = assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, allowed_origins: :all) } + + assert_equal( + "allowed_origins must be an Array of Origin values or :any (got :all); " \ + ":any turns Origin validation off, for a server that requires a bearer token on every request", + error.message, + ) + end + + test "handle_request keeps the Host check with allowed_origins: :any" do + transport = StreamableHTTPTransport.new(@server, allowed_origins: :any) + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_HOST" => "evil.example.com", + "HTTP_ORIGIN" => "http://evil.example.com", + }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + + status, _headers, body = transport.handle_request(request) + assert_equal(403, status) + assert_equal("Forbidden: Invalid Host header", JSON.parse(body.first)["error"]["message"]) + ensure + transport&.close + end + test "handle_request allows a same-origin browser request" do request = create_rack_request( "POST", @@ -6187,6 +6236,31 @@ def string transport.close end + test "keeps a session bound to the Origin of its initialize with allowed_origins: :any" do + transport = StreamableHTTPTransport.new(@server, enable_json_response: true, allowed_origins: :any) + init = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => "https://app.example.com" }, + { jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json, + ) + session_id = transport.handle_request(init)[1]["mcp-session-id"] + + statuses = ["https://app.example.com", "https://other.example.com"].map do |origin| + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id, "HTTP_ORIGIN" => origin }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + transport.handle_request(request)[0] + end + + assert_equal([200, 403], statuses) + ensure + transport.close + end + test "allows a POST with a matching Origin and one with no Origin" do transport = StreamableHTTPTransport.new( @server,