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,