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