@@ -417,6 +417,174 @@ def test_send_request_raises_unauthorized_error
417417 assert_equal ( { method : "tools/list" , params : nil } , error . request )
418418 end
419419
420+ def test_send_request_redacts_the_authorization_header_from_the_original_error
421+ # Faraday keeps the request headers on the error it raises, so without redaction the bearer token
422+ # would travel into logs through `original_error` (also the `cause`) and its `inspect`.
423+ client = HTTP . new ( url : url , headers : { "Authorization" => "Bearer secret-token" } )
424+ stub_request ( :post , url ) . to_return ( status : 401 )
425+
426+ error = assert_raises ( RequestHandlerError ) do
427+ client . send_request ( request : { jsonrpc : "2.0" , id : "test_id" , method : "tools/list" } )
428+ end
429+
430+ assert_equal ( "[redacted]" , error . original_error . response [ :request ] [ :headers ] [ "Authorization" ] )
431+ assert_same ( error . original_error , error . cause )
432+ refute_includes ( error . original_error . inspect , "secret-token" )
433+ end
434+
435+ def test_send_notification_redacts_the_authorization_header_from_the_original_error
436+ client = HTTP . new ( url : url , headers : { "Authorization" => "Bearer secret-token" } )
437+ stub_request ( :post , url ) . to_return ( status : 500 )
438+
439+ error = assert_raises ( RequestHandlerError ) do
440+ client . send_notification ( notification : { jsonrpc : "2.0" , method : "notifications/initialized" } )
441+ end
442+
443+ assert_equal ( "[redacted]" , error . original_error . response [ :request ] [ :headers ] [ "Authorization" ] )
444+ refute_includes ( error . original_error . inspect , "secret-token" )
445+ end
446+
447+ def test_resuming_a_stream_redacts_the_authorization_header_from_the_original_error
448+ # The server closes the stream after a priming event (SEP-1699), so the client resumes it with
449+ # a GET carrying `Last-Event-ID`; that GET runs after the initial POST completes, still inside `send_request`.
450+ client = HTTP . new ( url : url , headers : { "Authorization" => "Bearer secret-token" } )
451+ request = { jsonrpc : "2.0" , id : "test_id" , method : "tools/call" , params : { name : "test_tool" , arguments : { } } }
452+ stub_request ( :post , url ) . with ( body : request . to_json ) . to_return (
453+ status : 200 ,
454+ headers : { "Content-Type" => "text/event-stream" } ,
455+ body : "id: event-1\n retry: 10\n data:\n \n " ,
456+ )
457+ stub_request ( :get , url ) . with ( headers : { "Last-Event-ID" => "event-1" } ) . to_return ( status : 500 )
458+
459+ error = assert_raises ( RequestHandlerError ) do
460+ client . send_request ( request : request )
461+ end
462+
463+ assert_equal ( "[redacted]" , error . original_error . response [ :request ] [ :headers ] [ "Authorization" ] )
464+ assert_same ( error . original_error , error . cause )
465+ refute_includes ( error . original_error . inspect , "secret-token" )
466+ end
467+
468+ def test_answering_a_server_request_redacts_the_authorization_header_from_the_original_error
469+ # The resumed stream carries a request from the server, which the client answers with a POST of its own.
470+ client = HTTP . new ( url : url , headers : { "Authorization" => "Bearer secret-token" } )
471+ request = { jsonrpc : "2.0" , id : "test_id" , method : "tools/call" , params : { name : "test_tool" , arguments : { } } }
472+ stub_request ( :post , url ) . with ( body : request . to_json ) . to_return (
473+ status : 200 ,
474+ headers : { "Content-Type" => "text/event-stream" } ,
475+ body : "id: event-1\n retry: 10\n data:\n \n " ,
476+ )
477+ stub_request ( :get , url ) . with ( headers : { "Last-Event-ID" => "event-1" } ) . to_return (
478+ status : 200 ,
479+ headers : { "Content-Type" => "text/event-stream" } ,
480+ body : "event: message\n id: event-2\n data: {\" jsonrpc\" :\" 2.0\" ,\" id\" :\" server_id\" ,\" method\" :\" roots/list\" }\n \n " ,
481+ )
482+ stub_request ( :post , url ) . with { |answer | answer . body . include? ( "server_id" ) } . to_return ( status : 500 )
483+
484+ error = assert_raises ( RequestHandlerError ) do
485+ client . send_request ( request : request )
486+ end
487+
488+ assert_equal ( "[redacted]" , error . original_error . response [ :request ] [ :headers ] [ "Authorization" ] )
489+ assert_same ( error . original_error , error . cause )
490+ refute_includes ( error . original_error . inspect , "secret-token" )
491+ end
492+
493+ def test_answering_a_buffered_server_request_redacts_the_authorization_header_from_the_original_error
494+ # An adapter without streaming support, like the test adapter, hands the SSE body over whole once
495+ # the POST has completed, so the answer to a server request it carries is sent outside that POST
496+ # and only the answer's own redaction covers it.
497+ stubs = Faraday ::Adapter ::Test ::Stubs . new do |stub |
498+ stub . post ( "/" ) do |env |
499+ if env . body . include? ( "server_id" )
500+ [ 500 , { } , "" ]
501+ else
502+ [
503+ 200 ,
504+ { "Content-Type" => "text/event-stream" } ,
505+ "event: message\n data: {\" jsonrpc\" :\" 2.0\" ,\" id\" :\" server_id\" ,\" method\" :\" roots/list\" }\n \n " ,
506+ ]
507+ end
508+ end
509+ end
510+ client = HTTP . new ( url : url , headers : { "Authorization" => "Bearer secret-token" } ) do |faraday |
511+ faraday . adapter ( :test , stubs )
512+ end
513+
514+ error = assert_raises ( RequestHandlerError ) do
515+ client . send_request ( request : { jsonrpc : "2.0" , id : "test_id" , method : "tools/list" } )
516+ end
517+
518+ assert_equal ( "[redacted]" , error . original_error . response [ :request ] [ :headers ] [ "Authorization" ] )
519+ assert_same ( error . original_error , error . cause )
520+ refute_includes ( error . original_error . inspect , "secret-token" )
521+ end
522+
523+ def test_send_request_redacts_the_authorization_header_from_a_parsing_error
524+ # A JSON middleware added through the connection block raises `Faraday::ParsingError` on a malformed body,
525+ # and that error retains the `Faraday::Response` itself rather than the Hash `raise_error` builds.
526+ # The body reaches the middleware through an adapter without streaming support, like the test adapter.
527+ stubs = Faraday ::Adapter ::Test ::Stubs . new do |stub |
528+ stub . post ( "/" ) { [ 200 , { "Content-Type" => "application/json" } , "{" ] }
529+ end
530+ client = HTTP . new ( url : url , headers : { "Authorization" => "Bearer secret-token" } ) do |faraday |
531+ faraday . response ( :json )
532+ faraday . adapter ( :test , stubs )
533+ end
534+
535+ error = assert_raises ( RequestHandlerError ) do
536+ client . send_request ( request : { jsonrpc : "2.0" , id : "test_id" , method : "tools/list" } )
537+ end
538+
539+ assert_instance_of ( Faraday ::ParsingError , error . original_error )
540+ assert_equal ( "[redacted]" , error . original_error . response . env . request_headers [ "Authorization" ] )
541+ refute_includes ( error . original_error . inspect , "secret-token" )
542+ end
543+
544+ def test_send_request_redacts_frozen_request_headers_through_a_copy
545+ # A middleware that freezes the request headers must not turn the redaction into a `FrozenError` raised
546+ # in place of the HTTP error, which would also skip the OAuth retry and the `RequestHandlerError` wrapping.
547+ freezing = Class . new ( Faraday ::Middleware ) do
548+ def call ( env )
549+ env . request_headers . freeze
550+ @app . call ( env )
551+ end
552+ end
553+ client = HTTP . new ( url : url , headers : { "Authorization" => "Bearer secret-token" } ) do |faraday |
554+ faraday . use ( freezing )
555+ end
556+ stub_request ( :post , url ) . to_return ( status : 401 )
557+
558+ error = assert_raises ( RequestHandlerError ) do
559+ client . send_request ( request : { jsonrpc : "2.0" , id : "test_id" , method : "tools/list" } )
560+ end
561+
562+ assert_equal ( "[redacted]" , error . original_error . response [ :request ] [ :headers ] [ "Authorization" ] )
563+ refute_includes ( error . original_error . inspect , "secret-token" )
564+ end
565+
566+ def test_send_request_redacts_a_lowercase_authorization_key_in_replaced_request_headers
567+ # A middleware that replaces the headers with a plain Hash loses the canonical spelling
568+ # `Faraday::Utils::Headers` guarantees, so the name is matched regardless of case.
569+ downcasing = Class . new ( Faraday ::Middleware ) do
570+ def call ( env )
571+ env . request_headers = env . request_headers . to_h . transform_keys ( &:downcase )
572+ @app . call ( env )
573+ end
574+ end
575+ client = HTTP . new ( url : url , headers : { "Authorization" => "Bearer secret-token" } ) do |faraday |
576+ faraday . use ( downcasing )
577+ end
578+ stub_request ( :post , url ) . to_return ( status : 401 )
579+
580+ error = assert_raises ( RequestHandlerError ) do
581+ client . send_request ( request : { jsonrpc : "2.0" , id : "test_id" , method : "tools/list" } )
582+ end
583+
584+ assert_equal ( "[redacted]" , error . original_error . response [ :request ] [ :headers ] [ "authorization" ] )
585+ refute_includes ( error . original_error . inspect , "secret-token" )
586+ end
587+
420588 def test_send_request_raises_forbidden_error
421589 request = {
422590 jsonrpc : "2.0" ,
@@ -2010,6 +2178,27 @@ def test_close_propagates_unauthorized_and_still_clears_state
20102178 assert_nil ( client . session_id )
20112179 end
20122180
2181+ def test_close_redacts_the_authorization_header_from_the_propagated_error
2182+ client = HTTP . new ( url : url , headers : { "Authorization" => "Bearer secret-token" } )
2183+ stub_request ( :post , url ) . to_return (
2184+ status : 200 ,
2185+ headers : { "Content-Type" => "application/json" , "Mcp-Session-Id" => "session-abc" } ,
2186+ body : { jsonrpc : "2.0" , result : { protocolVersion : "2025-11-25" } } . to_json ,
2187+ )
2188+ client . send_request ( request : { jsonrpc : "2.0" , id : "1" , method : "initialize" } )
2189+ stub_request ( :delete , url ) . to_return ( status : 401 )
2190+
2191+ error = assert_raises ( Faraday ::UnauthorizedError ) do
2192+ client . close
2193+ end
2194+
2195+ assert_equal ( "[redacted]" , error . response [ :request ] [ :headers ] [ "Authorization" ] )
2196+
2197+ # Only the `Authorization` header is replaced; the session header stays as it was sent.
2198+ assert_equal ( "session-abc" , error . response [ :request ] [ :headers ] [ "Mcp-Session-Id" ] )
2199+ refute_includes ( error . inspect , "secret-token" )
2200+ end
2201+
20132202 def test_close_propagates_connection_failure_and_still_clears_state
20142203 initialize_session
20152204 stub_request ( :delete , url ) . to_raise ( Faraday ::ConnectionFailed . new ( "connection refused" ) )
0 commit comments