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.
This commit is contained in:
Koichi ITO 2026-07-07 21:38:04 +09:00
commit 3546660531
3 changed files with 273 additions and 13 deletions

View file

@ -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)

View file

@ -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,

View file

@ -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)