From 35466605319a34e4c7808712ae9bb1ca1afb2356 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 7 Jul 2026 21:38:04 +0900 Subject: [PATCH] Add a Session-Ownership Hook to StreamableHTTPTransport ## Motivation and Context `StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates an incoming request by session existence and idle timeout only, with no binding to a client identity. An attacker who obtains a victim's session ID (network sniffing, logs, etc.) can therefore POST a `tools/call` on that session and the server executes it, silently poisoning the victim's session. The previously shipped 409 fix guards the GET SSE stream slot but does nothing for the POST write path, so this is not covered by it. Binding a session to a user is fundamentally the deploying application's responsibility: the transport hands the server only the JSON-RPC body string and never receives the authenticated principal (`Authorization`/`Origin` headers do not reach handlers), and the MCP spec frames session-to-user binding as a SHOULD. The Python, Go, and C# SDKs bind automatically only when an authenticated identity is present. This gives the Ruby SDK the seam to do the same: - A `session_request_validator` constructor option, called as `->(request, session_id) { true | false }` on every non-`initialize` POST, GET, and DELETE against an existing session; a falsy return rejects with HTTP 403. The gate is applied uniformly to regular requests, notifications, and client responses, not just tool calls - otherwise a stolen session ID could POST `notifications/cancelled` to cancel a victim's in-flight request, or a forged client response, by taking a branch the gate did not cover. `initialize` is exempt because it establishes the session. An application that has an authenticated principal uses the validator to enforce that the caller owns the session; without one, ownership is not enforced. - A limited built-in Origin-consistency check as defense in depth (not authentication): the `Origin` header is recorded at `initialize` and a later request whose `Origin` differs is rejected, but only when both are present. A non-browser client that omits `Origin` (curl, a script - i.e. the advisory's own PoC) is not stopped by this check; it only raises the bar against a browser-based cross-origin attacker. Enforcing ownership against a determined attacker requires the validator. Stateless mode retains no sessions, so neither check applies to it. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: a POST whose Origin differs from `initialize` is rejected with 403; a matching Origin and a no-Origin request both succeed; a custom `session_request_validator` both rejects (403) and allows (200) and receives the request and session ID; the validator also gates notification (`notifications/cancelled`) and client-response POSTs, not just regular requests; and stateless mode never consults the validator. ## Breaking Changes None. `session_request_validator` defaults to `nil`, and the built-in Origin check only rejects when both the session and the request carry an `Origin` and they differ, so existing applications are unaffected. --- README.md | 25 +++ .../transports/streamable_http_transport.rb | 71 +++++-- .../streamable_http_transport_test.rb | 190 ++++++++++++++++++ 3 files changed, 273 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e3b1142..14a2874 100644 --- a/README.md +++ b/README.md @@ -1798,6 +1798,31 @@ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session Stateless mode (`stateless: true`) retains no sessions, so neither limit applies to it. +#### Session Ownership + +`StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates incoming requests by session +existence and idle timeout only. It does not bind a session to a user, because the transport never receives +an authenticated identity on its own. A caller that obtains a valid session ID could therefore act on that session, +so binding a session to a user is the deploying application's responsibility (the MCP spec frames this as a SHOULD). + +The primary control is the `session_request_validator`. It is called as `->(request, session_id) { true | false }` +on every non-`initialize` POST, GET, and DELETE against an existing session (including notification and response POSTs, +so a stolen session ID cannot, for example, POST `notifications/cancelled` against a victim's request). A falsy return +rejects the request with HTTP 403. Use it to compare the request's authenticated principal against the one recorded +when the session was created: + +```ruby +transport = MCP::Server::Transports::StreamableHTTPTransport.new( + server, + session_request_validator: ->(request, session_id) { owns_session?(request, session_id) }, +) +``` + +Without a validator the transport does not enforce ownership. As a limited defense in depth (not authentication), +it also records the `Origin` header at `initialize` and rejects a later request whose `Origin` differs, but only +when both are present - a non-browser client that omits `Origin` (e.g. `curl` or a script) is not stopped by this check. +Enforcing ownership against a determined attacker requires supplying the validator with an authenticated principal. + ### Pagination The MCP Ruby SDK supports [pagination](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index fc63988..6f45503 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -58,6 +58,14 @@ module MCP # @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. + # @param session_request_validator [#call, nil] An optional + # `->(request, session_id) { true | false }` invoked on every non-`initialize` POST, GET, and DELETE + # against an existing session (regular requests, notifications, and client responses alike). + # Returning a falsy value rejects the request with HTTP 403. The SDK issues a random `SecureRandom.uuid` + # session ID and otherwise only checks existence/idle-timeout, so binding a session to a user is + # the deploying application's responsibility (the transport never receives the authenticated identity + # on its own); this is the seam to enforce ownership and mitigate session poisoning. Without a validator, + # ownership is not enforced. def initialize( server, stateless: false, @@ -66,15 +74,17 @@ module MCP max_sessions: DEFAULT_MAX_SESSIONS, allowed_origins: nil, allowed_hosts: nil, - dns_rebinding_protection: true + dns_rebinding_protection: true, + session_request_validator: nil ) super(server) - # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`. + # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`. @sessions = {} @mutex = Mutex.new @stateless = stateless @enable_json_response = enable_json_response + @session_request_validator = session_request_validator @dns_rebinding_protection = dns_rebinding_protection # Host names are case-insensitive, so the allow lists are compared down-cased. @@ -447,16 +457,25 @@ module MCP return session_not_found_response end - handle_initialization(body_string, body) - elsif notification?(body) - dispatch_notification(body_string, session_id) - handle_accepted - elsif response?(body) - return session_not_found_response if !@stateless && !session_exists?(session_id) - - handle_response(body, session_id: session_id) + handle_initialization(request, body_string, body) else - handle_regular_request(body_string, session_id, related_request_id: body[:id]) + # Ownership gate for every request against an existing session, applied uniformly to notifications, client responses, + # and regular requests. This covers write paths beyond tool calls - notably `notifications/cancelled`, which would + # otherwise let a stolen session ID cancel a victim's in-flight request. `initialize` is exempt (it establishes the session). + if !@stateless && session_id && !validate_session_request(request, session_id) + return forbidden_response + end + + if notification?(body) + dispatch_notification(body_string, session_id) + handle_accepted + elsif response?(body) + return session_not_found_response if !@stateless && !session_exists?(session_id) + + handle_response(body, session_id: session_id) + else + handle_regular_request(body_string, session_id, related_request_id: body[:id]) + end end rescue StandardError => e MCP.configuration.exception_reporter.call(e, { request: body_string }) @@ -481,6 +500,7 @@ module MCP error_response = validate_and_touch_session(session_id) return error_response if error_response + return forbidden_response unless validate_session_request(request, session_id) protocol_version_error = validate_protocol_version_header(request) return protocol_version_error if protocol_version_error @@ -503,6 +523,7 @@ module MCP return missing_session_id_response unless (session_id = extract_session_id(request)) return session_not_found_response unless session_exists?(session_id) + return forbidden_response unless validate_session_request(request, session_id) protocol_version_error = validate_protocol_version_header(request) return protocol_version_error if protocol_version_error @@ -564,6 +585,27 @@ module MCP request.env["HTTP_MCP_SESSION_ID"] end + # Session-ownership gate for requests against an existing session (the spec's session-binding guidance). + # The session ID alone is unguessable but not proof of ownership, so a stolen ID must not silently grant access. + # Two layers, both returning `false` to trigger a 403: + # + # - Built-in Origin consistency (defense in depth, not authentication): if the session recorded an `Origin` + # at `initialize` and this request carries a different one, reject. Both must be present to compare, + # so non-browser clients that send no `Origin` are unaffected. + # - The application-supplied `session_request_validator`, which can enforce true ownership when it has + # an authenticated principal. + def validate_session_request(request, session_id) + session = @mutex.synchronize { @sessions[session_id] } + return true unless session + + session_origin = session[:origin] + request_origin = request.env["HTTP_ORIGIN"] + return false if session_origin && request_origin && session_origin != request_origin + return @session_request_validator.call(request, session_id) if @session_request_validator + + true + end + def validate_accept_header(request, required_types) accept_header = request.env["HTTP_ACCEPT"] return not_acceptable_response(required_types) unless accept_header @@ -682,7 +724,7 @@ module MCP handle_accepted end - def handle_initialization(body_string, body) + def handle_initialization(request, body_string, body) session_id = nil if @stateless @@ -709,6 +751,9 @@ module MCP get_sse_stream: nil, server_session: server_session, last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC), + # Captured for the built-in Origin-consistency defense in `validate_session_request`. + # Not authentication. + origin: request.env["HTTP_ORIGIN"], } true end @@ -962,7 +1007,7 @@ module MCP authority.delete_suffix(default_port) == host.downcase.delete_suffix(default_port) end - def forbidden_response(message) + def forbidden_response(message = "Forbidden: session request validation failed") json_rpc_error_response( status: 403, code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 13bb40f..36d08bc 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -4741,6 +4741,196 @@ module MCP transport.close end + test "rejects a POST to a session whose Origin differs from initialize with 403" do + # Both origins are allow-listed so they pass DNS rebinding protection; the rejection + # below comes from the per-session Origin-consistency gate, not from the rebinding check. + transport = StreamableHTTPTransport.new( + @server, + enable_json_response: true, + allowed_origins: ["https://app.example.com", "https://evil.example.com"], + ) + init = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => "https://app.example.com" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] + + attacker = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_ORIGIN" => "https://evil.example.com", + }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + response = transport.handle_request(attacker) + + assert_equal(403, response[0]) + ensure + transport.close + end + + test "allows a POST with a matching Origin and one with no Origin" do + transport = StreamableHTTPTransport.new( + @server, + enable_json_response: true, + allowed_origins: ["https://app.example.com"], + ) + init = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => "https://app.example.com" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] + + same_origin = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_ORIGIN" => "https://app.example.com", + }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + + # A non-browser client that sends no Origin is not rejected by the built-in check. + no_origin = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", method: "ping", id: "2" }.to_json, + ) + + assert_equal(200, transport.handle_request(same_origin)[0]) + assert_equal(200, transport.handle_request(no_origin)[0]) + ensure + transport.close + end + + test "a custom session_request_validator rejects and allows requests" do + allowed = { value: true } + validator = ->(_request, _session_id) { allowed[:value] } + transport = StreamableHTTPTransport.new(@server, enable_json_response: true, session_request_validator: validator) + init = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] + + ping = lambda do + create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + end + + assert_equal(200, transport.handle_request(ping.call)[0]) + allowed[:value] = false + assert_equal(403, transport.handle_request(ping.call)[0]) + ensure + transport.close + end + + test "the validator also gates notification and response POSTs, not just regular requests" do + # A stolen session ID must not be able to POST `notifications/cancelled` (which would cancel a victim's in-flight request) + # or a client response by bypassing the ownership gate. `initialize` establishes the session and is exempt, + # so a single transport whose validator always rejects still initializes. + transport = StreamableHTTPTransport.new( + @server, + enable_json_response: true, + session_request_validator: ->(_request, _session_id) { false }, + ) + init = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] + refute_nil(session_id) + + headers = { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id } + cancelled = create_rack_request( + "POST", + "/", + headers, + { jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: "1" } }.to_json, + ) + client_response = create_rack_request( + "POST", + "/", + headers, + { jsonrpc: "2.0", id: "1", result: {} }.to_json, + ) + + assert_equal(403, transport.handle_request(cancelled)[0]) + assert_equal(403, transport.handle_request(client_response)[0]) + ensure + transport.close + end + + test "session_request_validator receives the request and session id" do + seen = {} + validator = ->(request, session_id) { + seen[:origin] = request.env["HTTP_ORIGIN"] + seen[:session_id] = session_id + true + } + transport = StreamableHTTPTransport.new( + @server, + enable_json_response: true, + allowed_origins: ["https://app.example.com"], + session_request_validator: validator, + ) + init = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + session_id = transport.handle_request(init)[1]["Mcp-Session-Id"] + + transport.handle_request(create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id, "HTTP_ORIGIN" => "https://app.example.com" }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + )) + + assert_equal(session_id, seen[:session_id]) + assert_equal("https://app.example.com", seen[:origin]) + ensure + transport.close + end + + test "session validation does not apply in stateless mode" do + rejecting = ->(_request, _session_id) { false } + transport = StreamableHTTPTransport.new(@server, stateless: true, enable_json_response: true, session_request_validator: rejecting) + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "ping", id: "1" }.to_json, + ) + response = transport.handle_request(request) + + # Stateless mode keeps no sessions, so the validator is never consulted. + assert_equal(200, response[0]) + ensure + transport.close + end + private def create_rack_request(method, path, headers, body = nil)