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
|
|
@ -24,7 +24,23 @@ module MCP
|
|||
"Connection" => "keep-alive",
|
||||
}.freeze
|
||||
|
||||
def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil)
|
||||
# Secure defaults for stateful mode. Without a finite idle timeout, sessions live until an explicit client DELETE,
|
||||
# so an unauthenticated `initialize` flood retains unbounded `ServerSession` objects until memory is exhausted.
|
||||
# These defaults expire idle sessions and cap the concurrent count, like the C# SDK (the only reference SDK that
|
||||
# hardens this by default, with a 2h idle timeout and a 10k idle-session count). One difference: at the cap this transport
|
||||
# rejects a new `initialize` with 503 (after reclaiming any already-expired slots), whereas the C# SDK evicts
|
||||
# the oldest idle session. Rejecting keeps established sessions stable and avoids evicting a legitimate idle session on
|
||||
# an attacker's behalf, at the cost of refusing new sessions while genuinely full. Pass `session_idle_timeout: nil` to
|
||||
# opt out of expiry and `max_sessions: nil` to opt out of the cap.
|
||||
DEFAULT_SESSION_IDLE_TIMEOUT = 1800
|
||||
DEFAULT_MAX_SESSIONS = 10_000
|
||||
|
||||
# Distinguishes "argument omitted, apply the secure default" from an explicit `nil` (opt out of expiry).
|
||||
UNSET_IDLE_TIMEOUT = Object.new.freeze
|
||||
private_constant :UNSET_IDLE_TIMEOUT
|
||||
|
||||
def initialize(server, stateless: false, enable_json_response: false,
|
||||
session_idle_timeout: UNSET_IDLE_TIMEOUT, max_sessions: DEFAULT_MAX_SESSIONS)
|
||||
super(server)
|
||||
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
|
||||
@sessions = {}
|
||||
|
|
@ -32,9 +48,16 @@ module MCP
|
|||
|
||||
@stateless = stateless
|
||||
@enable_json_response = enable_json_response
|
||||
@session_idle_timeout = session_idle_timeout
|
||||
@pending_responses = {}
|
||||
|
||||
# Resolve the idle timeout: an explicit value (including `nil` to opt out) wins; otherwise apply the secure default,
|
||||
# which does not apply to stateless mode since it retains no sessions.
|
||||
@session_idle_timeout = if session_idle_timeout.equal?(UNSET_IDLE_TIMEOUT)
|
||||
stateless ? nil : DEFAULT_SESSION_IDLE_TIMEOUT
|
||||
else
|
||||
session_idle_timeout
|
||||
end
|
||||
|
||||
if @session_idle_timeout
|
||||
if @stateless
|
||||
raise ArgumentError, "session_idle_timeout is not supported in stateless mode."
|
||||
|
|
@ -43,6 +66,13 @@ module MCP
|
|||
end
|
||||
end
|
||||
|
||||
unless max_sessions.nil? || (max_sessions.is_a?(Integer) && max_sessions > 0)
|
||||
raise ArgumentError, "max_sessions must be a positive Integer or nil"
|
||||
end
|
||||
|
||||
# The cap guards the stateful session store; stateless mode keeps none.
|
||||
@max_sessions = stateless ? nil : max_sessions
|
||||
|
||||
start_reaper_thread if @session_idle_timeout
|
||||
end
|
||||
|
||||
|
|
@ -622,13 +652,30 @@ module MCP
|
|||
session_id = SecureRandom.uuid
|
||||
server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
|
||||
|
||||
@mutex.synchronize do
|
||||
# Cap the concurrent session count so an `initialize` flood cannot retain unbounded sessions until memory is exhausted.
|
||||
# The check and insert share the mutex so concurrent initializes cannot race past the limit.
|
||||
reclaimed = []
|
||||
inserted = @mutex.synchronize do
|
||||
# When at the cap, first reclaim slots held by already-expired sessions the 60s reaper has not yet collected,
|
||||
# so the cap rejects only when genuinely full rather than up to a reaper interval after sessions expired.
|
||||
if @max_sessions && @sessions.size >= @max_sessions
|
||||
@sessions.each_key.select { |id| session_expired?(@sessions[id]) }.each do |id|
|
||||
cleanup_and_collect_stream(id, reclaimed)
|
||||
end
|
||||
end
|
||||
|
||||
next false if @max_sessions && @sessions.size >= @max_sessions
|
||||
|
||||
@sessions[session_id] = {
|
||||
get_sse_stream: nil,
|
||||
server_session: server_session,
|
||||
last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
||||
}
|
||||
true
|
||||
end
|
||||
|
||||
reclaimed.each { |stream| close_stream_safely(stream) }
|
||||
return too_many_sessions_response unless inserted
|
||||
end
|
||||
|
||||
response = server_session.handle_json(body_string)
|
||||
|
|
@ -655,6 +702,14 @@ module MCP
|
|||
[202, {}, []]
|
||||
end
|
||||
|
||||
def too_many_sessions_response
|
||||
json_rpc_error_response(
|
||||
status: 503,
|
||||
code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
|
||||
message: "Service unavailable: maximum concurrent sessions (#{@max_sessions}) reached",
|
||||
)
|
||||
end
|
||||
|
||||
def handle_regular_request(body_string, session_id, related_request_id: nil)
|
||||
server_session = nil
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue