Skip to content

Commit 0ede5fc

Browse files
authored
Merge commit from fork
Add a Session-Ownership Hook to StreamableHTTPTransport
2 parents 4b400be + 3546660 commit 0ede5fc

3 files changed

Lines changed: 273 additions & 13 deletions

File tree

‎README.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1798,6 +1798,31 @@ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session
17981798

17991799
Stateless mode (`stateless: true`) retains no sessions, so neither limit applies to it.
18001800

1801+
#### Session Ownership
1802+
1803+
`StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates incoming requests by session
1804+
existence and idle timeout only. It does not bind a session to a user, because the transport never receives
1805+
an authenticated identity on its own. A caller that obtains a valid session ID could therefore act on that session,
1806+
so binding a session to a user is the deploying application's responsibility (the MCP spec frames this as a SHOULD).
1807+
1808+
The primary control is the `session_request_validator`. It is called as `->(request, session_id) { true | false }`
1809+
on every non-`initialize` POST, GET, and DELETE against an existing session (including notification and response POSTs,
1810+
so a stolen session ID cannot, for example, POST `notifications/cancelled` against a victim's request). A falsy return
1811+
rejects the request with HTTP 403. Use it to compare the request's authenticated principal against the one recorded
1812+
when the session was created:
1813+
1814+
```ruby
1815+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
1816+
server,
1817+
session_request_validator: ->(request, session_id) { owns_session?(request, session_id) },
1818+
)
1819+
```
1820+
1821+
Without a validator the transport does not enforce ownership. As a limited defense in depth (not authentication),
1822+
it also records the `Origin` header at `initialize` and rejects a later request whose `Origin` differs, but only
1823+
when both are present - a non-browser client that omits `Origin` (e.g. `curl` or a script) is not stopped by this check.
1824+
Enforcing ownership against a determined attacker requires supplying the validator with an authenticated principal.
1825+
18011826
### Pagination
18021827

18031828
The MCP Ruby SDK supports [pagination](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination)

‎lib/mcp/server/transports/streamable_http_transport.rb‎

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ class InvalidJsonError < StandardError; end
5858
# @param dns_rebinding_protection [Boolean] when `true` (default), validates the `Host` and
5959
# `Origin` headers to prevent DNS rebinding; pass `false` when an upstream proxy already
6060
# validates them.
61+
# @param session_request_validator [#call, nil] An optional
62+
# `->(request, session_id) { true | false }` invoked on every non-`initialize` POST, GET, and DELETE
63+
# against an existing session (regular requests, notifications, and client responses alike).
64+
# Returning a falsy value rejects the request with HTTP 403. The SDK issues a random `SecureRandom.uuid`
65+
# session ID and otherwise only checks existence/idle-timeout, so binding a session to a user is
66+
# the deploying application's responsibility (the transport never receives the authenticated identity
67+
# on its own); this is the seam to enforce ownership and mitigate session poisoning. Without a validator,
68+
# ownership is not enforced.
6169
def initialize(
6270
server,
6371
stateless: false,
@@ -66,15 +74,17 @@ def initialize(
6674
max_sessions: DEFAULT_MAX_SESSIONS,
6775
allowed_origins: nil,
6876
allowed_hosts: nil,
69-
dns_rebinding_protection: true
77+
dns_rebinding_protection: true,
78+
session_request_validator: nil
7079
)
7180
super(server)
72-
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
81+
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`.
7382
@sessions = {}
7483
@mutex = Mutex.new
7584

7685
@stateless = stateless
7786
@enable_json_response = enable_json_response
87+
@session_request_validator = session_request_validator
7888
@dns_rebinding_protection = dns_rebinding_protection
7989

8090
# Host names are case-insensitive, so the allow lists are compared down-cased.
@@ -447,16 +457,25 @@ def handle_post(request)
447457
return session_not_found_response
448458
end
449459

450-
handle_initialization(body_string, body)
451-
elsif notification?(body)
452-
dispatch_notification(body_string, session_id)
453-
handle_accepted
454-
elsif response?(body)
455-
return session_not_found_response if !@stateless && !session_exists?(session_id)
456-
457-
handle_response(body, session_id: session_id)
460+
handle_initialization(request, body_string, body)
458461
else
459-
handle_regular_request(body_string, session_id, related_request_id: body[:id])
462+
# Ownership gate for every request against an existing session, applied uniformly to notifications, client responses,
463+
# and regular requests. This covers write paths beyond tool calls - notably `notifications/cancelled`, which would
464+
# otherwise let a stolen session ID cancel a victim's in-flight request. `initialize` is exempt (it establishes the session).
465+
if !@stateless && session_id && !validate_session_request(request, session_id)
466+
return forbidden_response
467+
end
468+
469+
if notification?(body)
470+
dispatch_notification(body_string, session_id)
471+
handle_accepted
472+
elsif response?(body)
473+
return session_not_found_response if !@stateless && !session_exists?(session_id)
474+
475+
handle_response(body, session_id: session_id)
476+
else
477+
handle_regular_request(body_string, session_id, related_request_id: body[:id])
478+
end
460479
end
461480
rescue StandardError => e
462481
MCP.configuration.exception_reporter.call(e, { request: body_string })
@@ -481,6 +500,7 @@ def handle_get(request)
481500

482501
error_response = validate_and_touch_session(session_id)
483502
return error_response if error_response
503+
return forbidden_response unless validate_session_request(request, session_id)
484504

485505
protocol_version_error = validate_protocol_version_header(request)
486506
return protocol_version_error if protocol_version_error
@@ -503,6 +523,7 @@ def handle_delete(request)
503523

504524
return missing_session_id_response unless (session_id = extract_session_id(request))
505525
return session_not_found_response unless session_exists?(session_id)
526+
return forbidden_response unless validate_session_request(request, session_id)
506527

507528
protocol_version_error = validate_protocol_version_header(request)
508529
return protocol_version_error if protocol_version_error
@@ -564,6 +585,27 @@ def extract_session_id(request)
564585
request.env["HTTP_MCP_SESSION_ID"]
565586
end
566587

588+
# Session-ownership gate for requests against an existing session (the spec's session-binding guidance).
589+
# The session ID alone is unguessable but not proof of ownership, so a stolen ID must not silently grant access.
590+
# Two layers, both returning `false` to trigger a 403:
591+
#
592+
# - Built-in Origin consistency (defense in depth, not authentication): if the session recorded an `Origin`
593+
# at `initialize` and this request carries a different one, reject. Both must be present to compare,
594+
# so non-browser clients that send no `Origin` are unaffected.
595+
# - The application-supplied `session_request_validator`, which can enforce true ownership when it has
596+
# an authenticated principal.
597+
def validate_session_request(request, session_id)
598+
session = @mutex.synchronize { @sessions[session_id] }
599+
return true unless session
600+
601+
session_origin = session[:origin]
602+
request_origin = request.env["HTTP_ORIGIN"]
603+
return false if session_origin && request_origin && session_origin != request_origin
604+
return @session_request_validator.call(request, session_id) if @session_request_validator
605+
606+
true
607+
end
608+
567609
def validate_accept_header(request, required_types)
568610
accept_header = request.env["HTTP_ACCEPT"]
569611
return not_acceptable_response(required_types) unless accept_header
@@ -682,7 +724,7 @@ def handle_response(body, session_id:)
682724
handle_accepted
683725
end
684726

685-
def handle_initialization(body_string, body)
727+
def handle_initialization(request, body_string, body)
686728
session_id = nil
687729

688730
if @stateless
@@ -709,6 +751,9 @@ def handle_initialization(body_string, body)
709751
get_sse_stream: nil,
710752
server_session: server_session,
711753
last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
754+
# Captured for the built-in Origin-consistency defense in `validate_session_request`.
755+
# Not authentication.
756+
origin: request.env["HTTP_ORIGIN"],
712757
}
713758
true
714759
end
@@ -962,7 +1007,7 @@ def same_origin?(origin, request)
9621007
authority.delete_suffix(default_port) == host.downcase.delete_suffix(default_port)
9631008
end
9641009

965-
def forbidden_response(message)
1010+
def forbidden_response(message = "Forbidden: session request validation failed")
9661011
json_rpc_error_response(
9671012
status: 403,
9681013
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,

‎test/mcp/server/transports/streamable_http_transport_test.rb‎

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4741,6 +4741,196 @@ def string
47414741
transport.close
47424742
end
47434743

4744+
test "rejects a POST to a session whose Origin differs from initialize with 403" do
4745+
# Both origins are allow-listed so they pass DNS rebinding protection; the rejection
4746+
# below comes from the per-session Origin-consistency gate, not from the rebinding check.
4747+
transport = StreamableHTTPTransport.new(
4748+
@server,
4749+
enable_json_response: true,
4750+
allowed_origins: ["https://app.example.com", "https://evil.example.com"],
4751+
)
4752+
init = create_rack_request(
4753+
"POST",
4754+
"/",
4755+
{ "CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => "https://app.example.com" },
4756+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
4757+
)
4758+
session_id = transport.handle_request(init)[1]["Mcp-Session-Id"]
4759+
4760+
attacker = create_rack_request(
4761+
"POST",
4762+
"/",
4763+
{
4764+
"CONTENT_TYPE" => "application/json",
4765+
"HTTP_MCP_SESSION_ID" => session_id,
4766+
"HTTP_ORIGIN" => "https://evil.example.com",
4767+
},
4768+
{ jsonrpc: "2.0", method: "ping", id: "1" }.to_json,
4769+
)
4770+
response = transport.handle_request(attacker)
4771+
4772+
assert_equal(403, response[0])
4773+
ensure
4774+
transport.close
4775+
end
4776+
4777+
test "allows a POST with a matching Origin and one with no Origin" do
4778+
transport = StreamableHTTPTransport.new(
4779+
@server,
4780+
enable_json_response: true,
4781+
allowed_origins: ["https://app.example.com"],
4782+
)
4783+
init = create_rack_request(
4784+
"POST",
4785+
"/",
4786+
{ "CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => "https://app.example.com" },
4787+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
4788+
)
4789+
session_id = transport.handle_request(init)[1]["Mcp-Session-Id"]
4790+
4791+
same_origin = create_rack_request(
4792+
"POST",
4793+
"/",
4794+
{
4795+
"CONTENT_TYPE" => "application/json",
4796+
"HTTP_MCP_SESSION_ID" => session_id,
4797+
"HTTP_ORIGIN" => "https://app.example.com",
4798+
},
4799+
{ jsonrpc: "2.0", method: "ping", id: "1" }.to_json,
4800+
)
4801+
4802+
# A non-browser client that sends no Origin is not rejected by the built-in check.
4803+
no_origin = create_rack_request(
4804+
"POST",
4805+
"/",
4806+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
4807+
{ jsonrpc: "2.0", method: "ping", id: "2" }.to_json,
4808+
)
4809+
4810+
assert_equal(200, transport.handle_request(same_origin)[0])
4811+
assert_equal(200, transport.handle_request(no_origin)[0])
4812+
ensure
4813+
transport.close
4814+
end
4815+
4816+
test "a custom session_request_validator rejects and allows requests" do
4817+
allowed = { value: true }
4818+
validator = ->(_request, _session_id) { allowed[:value] }
4819+
transport = StreamableHTTPTransport.new(@server, enable_json_response: true, session_request_validator: validator)
4820+
init = create_rack_request(
4821+
"POST",
4822+
"/",
4823+
{ "CONTENT_TYPE" => "application/json" },
4824+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
4825+
)
4826+
session_id = transport.handle_request(init)[1]["Mcp-Session-Id"]
4827+
4828+
ping = lambda do
4829+
create_rack_request(
4830+
"POST",
4831+
"/",
4832+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
4833+
{ jsonrpc: "2.0", method: "ping", id: "1" }.to_json,
4834+
)
4835+
end
4836+
4837+
assert_equal(200, transport.handle_request(ping.call)[0])
4838+
allowed[:value] = false
4839+
assert_equal(403, transport.handle_request(ping.call)[0])
4840+
ensure
4841+
transport.close
4842+
end
4843+
4844+
test "the validator also gates notification and response POSTs, not just regular requests" do
4845+
# A stolen session ID must not be able to POST `notifications/cancelled` (which would cancel a victim's in-flight request)
4846+
# or a client response by bypassing the ownership gate. `initialize` establishes the session and is exempt,
4847+
# so a single transport whose validator always rejects still initializes.
4848+
transport = StreamableHTTPTransport.new(
4849+
@server,
4850+
enable_json_response: true,
4851+
session_request_validator: ->(_request, _session_id) { false },
4852+
)
4853+
init = create_rack_request(
4854+
"POST",
4855+
"/",
4856+
{ "CONTENT_TYPE" => "application/json" },
4857+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
4858+
)
4859+
session_id = transport.handle_request(init)[1]["Mcp-Session-Id"]
4860+
refute_nil(session_id)
4861+
4862+
headers = { "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id }
4863+
cancelled = create_rack_request(
4864+
"POST",
4865+
"/",
4866+
headers,
4867+
{ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: "1" } }.to_json,
4868+
)
4869+
client_response = create_rack_request(
4870+
"POST",
4871+
"/",
4872+
headers,
4873+
{ jsonrpc: "2.0", id: "1", result: {} }.to_json,
4874+
)
4875+
4876+
assert_equal(403, transport.handle_request(cancelled)[0])
4877+
assert_equal(403, transport.handle_request(client_response)[0])
4878+
ensure
4879+
transport.close
4880+
end
4881+
4882+
test "session_request_validator receives the request and session id" do
4883+
seen = {}
4884+
validator = ->(request, session_id) {
4885+
seen[:origin] = request.env["HTTP_ORIGIN"]
4886+
seen[:session_id] = session_id
4887+
true
4888+
}
4889+
transport = StreamableHTTPTransport.new(
4890+
@server,
4891+
enable_json_response: true,
4892+
allowed_origins: ["https://app.example.com"],
4893+
session_request_validator: validator,
4894+
)
4895+
init = create_rack_request(
4896+
"POST",
4897+
"/",
4898+
{ "CONTENT_TYPE" => "application/json" },
4899+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
4900+
)
4901+
session_id = transport.handle_request(init)[1]["Mcp-Session-Id"]
4902+
4903+
transport.handle_request(create_rack_request(
4904+
"POST",
4905+
"/",
4906+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id, "HTTP_ORIGIN" => "https://app.example.com" },
4907+
{ jsonrpc: "2.0", method: "ping", id: "1" }.to_json,
4908+
))
4909+
4910+
assert_equal(session_id, seen[:session_id])
4911+
assert_equal("https://app.example.com", seen[:origin])
4912+
ensure
4913+
transport.close
4914+
end
4915+
4916+
test "session validation does not apply in stateless mode" do
4917+
rejecting = ->(_request, _session_id) { false }
4918+
transport = StreamableHTTPTransport.new(@server, stateless: true, enable_json_response: true, session_request_validator: rejecting)
4919+
4920+
request = create_rack_request(
4921+
"POST",
4922+
"/",
4923+
{ "CONTENT_TYPE" => "application/json" },
4924+
{ jsonrpc: "2.0", method: "ping", id: "1" }.to_json,
4925+
)
4926+
response = transport.handle_request(request)
4927+
4928+
# Stateless mode keeps no sessions, so the validator is never consulted.
4929+
assert_equal(200, response[0])
4930+
ensure
4931+
transport.close
4932+
end
4933+
47444934
private
47454935

47464936
def create_rack_request(method, path, headers, body = nil)

0 commit comments

Comments
 (0)