Bound Stateful Session Retention to Prevent an Initialize-Flood DoS
## Motivation and Context In its default configuration `StreamableHTTPTransport` never expired sessions: every successful `initialize` stored a new `ServerSession` under a fresh UUID, and the only removal path was an explicit client DELETE. An unauthenticated attacker could repeatedly initialize and immediately disconnect, forcing the server to retain an unbounded number of sessions until memory is exhausted; the reporter created and held 50,000 sessions in 26.6 seconds from a single client. The reaper existed but only started when `session_idle_timeout` was set, and there was no cap on the concurrent session count, so the insecure behavior was the default. Of the reference SDKs only C# hardens this by default (a finite idle timeout plus a max session count); TypeScript, Python, and Go all leave sessions unbounded out of the box, and the spec permits (MAY) session expiry. This adopts secure defaults in the same spirit as C#, with one deliberate difference at the cap (reject vs. evict, below): - `session_idle_timeout` now defaults to a finite 1800 seconds (30 minutes) in stateful mode, so the existing reaper starts by default and idle sessions are reclaimed. Passing `session_idle_timeout: nil` opts out (the previous unbounded behavior); stateless mode retains no sessions and keeps no timeout regardless. An internal sentinel distinguishes "argument omitted, apply the default" from an explicit `nil`. - A new `max_sessions` keyword (default 10000) caps the concurrent session count. `handle_initialization` checks and inserts under the existing mutex so concurrent initializes cannot race past the limit. At the cap it first reclaims slots held by already-expired sessions the 60s reaper has not yet collected (so the cap rejects only when genuinely full, not up to a reaper interval after sessions expired), and if still full rejects the new `initialize` with HTTP 503. Unlike the C# SDK, which evicts the oldest idle session, this rejects: established sessions stay stable and a legitimate idle session is not evicted on an attacker's behalf, at the cost of refusing new sessions while full; a global cap thus bounds memory but can convert the memory-exhaustion DoS into a session-slot-exhaustion DoS, which the idle timeout drains over time. Passing `max_sessions: nil` opts out; the cap does not apply to stateless mode. The value is validated as a positive Integer or `nil`. ## How Has This Been Tested? New and updated tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: the finite idle timeout is applied and the reaper starts by default while normal request flow is unaffected, `session_idle_timeout: nil` and stateless mode both keep no timeout/reaper, a new `initialize` at `max_sessions` is rejected with 503 while the store stays at the cap, an already-expired session is reclaimed in-line so a new initialize succeeds at the cap instead of being rejected, `max_sessions: nil` opts out, and an invalid `max_sessions` raises `ArgumentError`. The pre-existing test asserting the old never-expire default was rewritten to the new secure default. ## Breaking Changes Stateful deployments now expire sessions after 30 minutes of inactivity and reject new sessions past 10000 concurrent, where before sessions lived until an explicit DELETE. This is a security-hardening behavior change, documented in the README; deployments that require the old unbounded behavior can pass `session_idle_timeout: nil` and `max_sessions: nil`.
This commit is contained in:
parent
8b1a41aedd
commit
afb968c468
1 changed files with 163 additions and 18 deletions
|
|
@ -3808,9 +3808,14 @@ module MCP
|
|||
transport.close
|
||||
end
|
||||
|
||||
test "default session_idle_timeout is nil and sessions do not expire" do
|
||||
test "applies a finite session_idle_timeout by default and starts the reaper" do
|
||||
# Secure default: stateful mode expires idle sessions out of the box.
|
||||
transport = StreamableHTTPTransport.new(@server)
|
||||
assert_nil(transport.instance_variable_get(:@reaper_thread))
|
||||
assert_equal(
|
||||
StreamableHTTPTransport::DEFAULT_SESSION_IDLE_TIMEOUT,
|
||||
transport.instance_variable_get(:@session_idle_timeout),
|
||||
)
|
||||
assert(transport.instance_variable_get(:@reaper_thread).alive?)
|
||||
|
||||
init_request = create_rack_request(
|
||||
"POST",
|
||||
|
|
@ -3818,25 +3823,101 @@ module MCP
|
|||
{ "CONTENT_TYPE" => "application/json" },
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||
)
|
||||
init_response = transport.handle_request(init_request)
|
||||
session_id = init_response[1]["Mcp-Session-Id"]
|
||||
|
||||
# A freshly created session is well within the timeout, so normal use is unaffected.
|
||||
session_id = transport.handle_request(init_request)[1]["Mcp-Session-Id"]
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
"HTTP_MCP_SESSION_ID" => session_id,
|
||||
},
|
||||
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
|
||||
{ jsonrpc: "2.0", method: "ping", id: "456" }.to_json,
|
||||
)
|
||||
|
||||
response = transport.handle_request(request)
|
||||
assert_equal(200, response[0])
|
||||
assert_equal(200, transport.handle_request(request)[0])
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
|
||||
test "session_idle_timeout: nil opts out of expiry" do
|
||||
transport = StreamableHTTPTransport.new(@server, session_idle_timeout: nil)
|
||||
assert_nil(transport.instance_variable_get(:@session_idle_timeout))
|
||||
assert_nil(transport.instance_variable_get(:@reaper_thread))
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
|
||||
test "stateless mode keeps no idle timeout despite the secure default" do
|
||||
transport = StreamableHTTPTransport.new(@server, stateless: true)
|
||||
assert_nil(transport.instance_variable_get(:@session_idle_timeout))
|
||||
assert_nil(transport.instance_variable_get(:@reaper_thread))
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
|
||||
test "rejects new initialize with 503 once max_sessions is reached" do
|
||||
transport = StreamableHTTPTransport.new(@server, max_sessions: 2)
|
||||
|
||||
2.times do
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json" },
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||
)
|
||||
assert_equal(200, transport.handle_request(request)[0])
|
||||
end
|
||||
|
||||
overflow = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json" },
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||
)
|
||||
response = transport.handle_request(overflow)
|
||||
|
||||
assert_equal(503, response[0])
|
||||
assert_equal(2, transport.instance_variable_get(:@sessions).size)
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
|
||||
test "at the cap, reclaims an already-expired session before rejecting" do
|
||||
# A very short idle timeout so the first session is expired by the time the cap would otherwise reject
|
||||
# a new initialize; the expired slot is reclaimed.
|
||||
transport = StreamableHTTPTransport.new(@server, max_sessions: 1, session_idle_timeout: 0.01)
|
||||
init = lambda do
|
||||
transport.handle_request(create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json" },
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||
))
|
||||
end
|
||||
|
||||
assert_equal(200, init.call[0])
|
||||
sleep(0.05) # let the first session's idle timer expire
|
||||
second = init.call
|
||||
|
||||
# The expired session was reclaimed in-line, so the second initialize succeeds (200) rather than
|
||||
# being rejected with 503, and the store stays at the cap.
|
||||
assert_equal(200, second[0])
|
||||
assert_equal(1, transport.instance_variable_get(:@sessions).size)
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
|
||||
test "max_sessions: nil opts out of the session cap" do
|
||||
transport = StreamableHTTPTransport.new(@server, max_sessions: nil)
|
||||
assert_nil(transport.instance_variable_get(:@max_sessions))
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
|
||||
test "raises ArgumentError for an invalid max_sessions" do
|
||||
[0, -1, 1.5, "10"].each do |invalid|
|
||||
assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, max_sessions: invalid) }
|
||||
end
|
||||
end
|
||||
|
||||
test "raises ArgumentError when session_idle_timeout is zero" do
|
||||
error = assert_raises(ArgumentError) do
|
||||
StreamableHTTPTransport.new(@server, session_idle_timeout: 0)
|
||||
|
|
|
|||
Loading…
Reference in a new issue