Skip to content

Commit 314dd18

Browse files
authored
Merge pull request #591 from koic/redact_the_bearer_token_from_retained_transport_errors
Redact the Authorization header from the errors the HTTP transport retains
2 parents d565e12 + 19d67b7 commit 314dd18

3 files changed

Lines changed: 281 additions & 11 deletions

File tree

‎lib/mcp/client/http.rb‎

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -418,8 +418,10 @@ def send_request(request:)
418418
yield if block_given?
419419

420420
response = begin
421-
client.post("", request, session_headers.merge(request_metadata_headers(method, params))) do |req|
422-
req.options.on_data = stream.on_data
421+
redacting_authorization_header do
422+
client.post("", request, session_headers.merge(request_metadata_headers(method, params))) do |req|
423+
req.options.on_data = stream.on_data
424+
end
423425
end
424426
rescue StreamAbort
425427
nil
@@ -527,7 +529,7 @@ def send_request(request:)
527529
def send_notification(notification:)
528530
method = notification[:method] || notification["method"]
529531

530-
client.post("", notification, session_headers)
532+
redacting_authorization_header { client.post("", notification, session_headers) }
531533
nil
532534
rescue Faraday::Error => e
533535
raise RequestHandlerError.new(
@@ -555,7 +557,7 @@ def close
555557
end
556558

557559
begin
558-
client.delete("", nil, session_headers)
560+
redacting_authorization_header { client.delete("", nil, session_headers) }
559561
rescue Faraday::ClientError => e
560562
raise unless [404, 405].include?(e.response&.dig(:status))
561563
ensure
@@ -1050,6 +1052,51 @@ def remaining_reconnection_budget(deadline)
10501052
[deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0.001].max
10511053
end
10521054

1055+
# Runs a request on the transport's connection and, when Faraday raises, replaces the value of the `Authorization` header
1056+
# in the request headers the exception retains before it leaves the transport.
1057+
# Faraday's `raise_error` middleware keeps a reference to `env.request_headers` in `Faraday::Error#response`
1058+
# under `:request`, so the exception that becomes `RequestHandlerError#original_error` (and its `cause`) would otherwise print
1059+
# the bearer token through `inspect`, which is what error reporters and log lines do with it. The request is complete by
1060+
# the time the exception surfaces, and every request builds its own headers from the connection's defaults,
1061+
# so the rewrite reaches neither those defaults nor a later request.
1062+
def redacting_authorization_header
1063+
yield
1064+
rescue Faraday::Error => e
1065+
redact_authorization_header!(e)
1066+
raise
1067+
end
1068+
1069+
# `raise_error` retains the request as a Hash under `:request`; a JSON middleware the customizer added raises
1070+
# `Faraday::ParsingError` with the `Faraday::Response` itself, whose `env` holds the request headers.
1071+
def redact_authorization_header!(error)
1072+
response = error.response
1073+
1074+
if response.is_a?(Hash)
1075+
request = response[:request]
1076+
redact_authorization_header_in!(request, :headers) if request.is_a?(Hash)
1077+
elsif response.respond_to?(:env) && response.env.respond_to?(:request_headers)
1078+
redact_authorization_header_in!(response.env, :request_headers)
1079+
end
1080+
end
1081+
1082+
# The headers are a `Faraday::Utils::Headers`, which spells the name `Authorization` whatever the caller wrote,
1083+
# unless a customizer middleware replaced them with a plain Hash or froze them: the name is matched regardless
1084+
# of case so such a Hash is covered too, and a frozen object is swapped for a copy, so the redaction never
1085+
# raises in place of the error it is redacting.
1086+
def redact_authorization_header_in!(holder, key)
1087+
headers = holder[key]
1088+
return unless headers.is_a?(Hash)
1089+
1090+
names = headers.each_key.select { |name| name.to_s.casecmp?("authorization") }
1091+
return if names.empty?
1092+
1093+
headers = holder[key] = headers.dup if headers.frozen?
1094+
1095+
names.each do |name|
1096+
headers[name] = "[redacted]"
1097+
end
1098+
end
1099+
10531100
def require_faraday!
10541101
require "faraday"
10551102
rescue LoadError
@@ -1162,7 +1209,7 @@ def dispatch_server_request(message)
11621209
end
11631210

11641211
def send_client_response(response)
1165-
client.post("", response, session_headers)
1212+
redacting_authorization_header { client.post("", response, session_headers) }
11661213
end
11671214

11681215
def parse_json_buffer(buffer, method, params)
@@ -1225,12 +1272,14 @@ def await_response_after_disconnect(stream, method, params)
12251272
read_timeout = remaining_reconnection_budget(deadline)
12261273

12271274
reconnect_response = begin
1228-
client.get("") do |req|
1229-
req.headers.update(session_headers)
1230-
req.headers["Accept"] = SSE_ACCEPT_HEADER
1231-
req.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id
1232-
req.options.read_timeout = read_timeout
1233-
req.options.on_data = stream.on_data
1275+
redacting_authorization_header do
1276+
client.get("") do |req|
1277+
req.headers.update(session_headers)
1278+
req.headers["Accept"] = SSE_ACCEPT_HEADER
1279+
req.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id
1280+
req.options.read_timeout = read_timeout
1281+
req.options.on_data = stream.on_data
1282+
end
12341283
end
12351284
rescue StreamAbort
12361285
# The awaited response arrived on the reconnected stream.

‎test/mcp/client/http_test.rb‎

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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\nretry: 10\ndata:\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\nretry: 10\ndata:\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\nid: event-2\ndata: {\"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\ndata: {\"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"))

‎test/mcp/client/oauth/http_oauth_test.rb‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,38 @@ def test_send_request_runs_the_oauth_flow_through_the_provider_customizer
308308
)
309309
end
310310

311+
def test_send_request_keeps_the_challenge_and_the_retried_bearer_through_the_redaction
312+
# The 401 error is redacted before the OAuth flow reads its `WWW-Authenticate` challenge, and the retry
313+
# builds its own headers, so the flow still runs and the retried request carries the token it produced.
314+
# The challenge names a metadata URL off the well-known paths, which are left unstubbed: were the challenge
315+
# lost with the redaction, discovery would fall back to those paths and the flow would fail.
316+
@prm_url = "https://srv.example.com/oauth/protected-resource"
317+
stub_step_up_authorization_server
318+
stub_request(:post, @mcp_url).with(
319+
headers: { "Authorization" => "Bearer initial-token" }
320+
).to_return(
321+
status: 401,
322+
headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") },
323+
body: "",
324+
)
325+
stub_request(:post, @mcp_url).with(
326+
headers: { "Authorization" => "Bearer escalated-token" }
327+
).to_return(
328+
status: 200,
329+
headers: { "Content-Type" => "application/json" },
330+
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
331+
)
332+
provider = build_step_up_provider
333+
334+
transport = HTTP.new(url: @mcp_url, oauth: provider)
335+
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
336+
337+
assert_equal({ "ok" => true }, response["result"])
338+
assert_equal("escalated-token", provider.access_token)
339+
assert_requested(:post, @mcp_url, headers: { "Authorization" => "Bearer initial-token" }, times: 1)
340+
assert_requested(:post, @mcp_url, headers: { "Authorization" => "Bearer escalated-token" }, times: 1)
341+
end
342+
311343
def test_send_request_does_not_follow_a_resource_metadata_challenge_off_the_server_origin
312344
# End to end over the transport, which is where the header is actually parsed:
313345
# a server that answers 401 must not be able to name an unrelated host in

0 commit comments

Comments
 (0)