Medium severity5.9NVD Advisory· Published Mar 27, 2026· Updated Apr 2, 2026
CVE-2026-33946
CVE-2026-33946
Description
MCP Ruby SDK is the official Ruby SDK for Model Context Protocol servers and clients. Prior to version 0.9.2, the Ruby SDK's streamable_http_transport.rb implementation contains a session hijacking vulnerability. An attacker who obtains a valid session ID can completely hijack the victim's Server-Sent Events (SSE) stream and intercept all real-time data. Version 0.9.2 contains a patch.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
mcpRubyGems | < 0.9.2 | 0.9.2 |
Affected products
1Patches
1db40143402d6Reject duplicate SSE connections with 409 to prevent stream hijacking
2 files changed · +75 −4
lib/mcp/server/transports/streamable_http_transport.rb+18 −4 modified@@ -142,6 +142,7 @@ def handle_get(request) return missing_session_id_response unless session_id return session_not_found_response unless session_exists?(session_id) + return session_already_connected_response if get_session_stream(session_id) setup_sse_stream(session_id) end @@ -315,6 +316,14 @@ def session_not_found_response [404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]] end + def session_already_connected_response + [ + 409, + { "Content-Type" => "application/json" }, + [{ error: "Conflict: Only one SSE stream is allowed per session" }.to_json], + ] + end + def setup_sse_stream(session_id) body = create_sse_body(session_id) @@ -329,17 +338,22 @@ def setup_sse_stream(session_id) def create_sse_body(session_id) proc do |stream| - store_stream_for_session(session_id, stream) - start_keepalive_thread(session_id) + stored = store_stream_for_session(session_id, stream) + start_keepalive_thread(session_id) if stored end end def store_stream_for_session(session_id, stream) @mutex.synchronize do - if @sessions[session_id] - @sessions[session_id][:stream] = stream + session = @sessions[session_id] + if session && !session[:stream] + session[:stream] = stream else + # Either session was removed, or another request already established a stream. stream.close + # `stream.close` may return a truthy value depending on the stream class. + # Explicitly return nil to guarantee a falsy return for callers. + nil end end end
test/mcp/server/transports/streamable_http_transport_test.rb+57 −0 modified@@ -272,6 +272,63 @@ class StreamableHTTPTransportTest < ActiveSupport::TestCase assert_equal "Missing session ID", body["error"] end + test "rejects duplicate SSE connection with 409" do + # Create a session + init_request = create_rack_request( + "POST", + "/", + { "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"] + + # Simulate an active SSE stream by storing a stream object in the session + mock_stream = StringIO.new + @transport.instance_variable_get(:@sessions)[session_id][:stream] = mock_stream + + # Attempt a second GET request for the same session + get_request = create_rack_request( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + + response = @transport.handle_request(get_request) + assert_equal 409, response[0] + assert_equal({ "Content-Type" => "application/json" }, response[1]) + + body = JSON.parse(response[2][0]) + assert_equal "Conflict: Only one SSE stream is allowed per session", body["error"] + end + + test "store_stream_for_session does not overwrite existing stream (TOCTOU guard)" do + # Create a session + init_request = create_rack_request( + "POST", + "/", + { "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"] + + # Establish stream A + stream_a = StringIO.new + @transport.send(:store_stream_for_session, session_id, stream_a) + assert_equal stream_a, @transport.instance_variable_get(:@sessions)[session_id][:stream] + + # Attempt to store stream B (simulating a racing request) + stream_b = StringIO.new + @transport.send(:store_stream_for_session, session_id, stream_b) + + # Stream A should still be the active stream + assert_equal stream_a, @transport.instance_variable_get(:@sessions)[session_id][:stream] + + # Stream B should have been closed + assert stream_b.closed? + end + test "handles GET request with invalid session ID" do request = create_rack_request( "GET",
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
11- github.com/modelcontextprotocol/csharp-sdk/blob/main/src/ModelContextProtocol.AspNetCore/SseHandler.csnvdPatchWEB
- github.com/modelcontextprotocol/go-sdk/blob/main/mcp/streamable.gonvdPatchWEB
- github.com/modelcontextprotocol/python-sdk/blob/main/src/mcp/server/streamable_http.pynvdPatchWEB
- github.com/modelcontextprotocol/ruby-sdk/blob/main/examples/streamable_http_server.rbnvdPatchWEB
- github.com/modelcontextprotocol/ruby-sdk/commit/db40143402d65b4fb6923cec42d2d72cb89b3874nvdPatchWEB
- github.com/modelcontextprotocol/ruby-sdk/security/advisories/GHSA-qvqr-5cv7-wh35nvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-qvqr-5cv7-wh35ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33946ghsaADVISORY
- github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.9.2nvdProductRelease NotesWEB
- github.com/rubysec/ruby-advisory-db/blob/master/gems/mcp/CVE-2026-33946.ymlghsaWEB
- hackerone.com/reports/3556146nvdPermissions RequiredWEB
News mentions
0No linked articles in our index yet.