From 38acf4795706820b35b44c0676c59a24dde166cd Mon Sep 17 00:00:00 2001 From: Ivars Belovs Date: Fri, 7 Aug 2026 10:55:06 +0300 Subject: [PATCH] Allow the public host in MCP DNS-rebinding protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcp 0.23.0 added DNS-rebinding protection to StreamableHTTPTransport: any Host header other than loopback is rejected with 403 "Forbidden: Invalid Host header" unless allow-listed via the new allowed_hosts: option. Production installs this gem directly (`gem install mail_mcp -v X`), which bypasses Gemfile.lock and resolves dependencies from the gemspec at image build time. `mcp "~> 0.14"` therefore floated up to 0.25.0 on a rebuild and started 403ing every authenticated /mcp call, with no code change. Both reported symptoms share this cause. resolve_mcp_context halts with 401 before the transport is constructed, so unauthenticated discovery still worked and the OAuth flow completed; the connector's first authenticated initialize call then hit the 403 and surfaced it as "Failed to complete OAuth authorization". Allow-list the BASE_URL host, and pin mcp to a single minor — since the gemspec constraints act as production pins here, a floor alone would leave the next mcp minor free to break this the same way. Co-Authored-By: Claude Opus 5 (1M context) --- Gemfile.lock | 16 ++++++++++------ lib/mail_mcp/app.rb | 13 ++++++++++++- mail_mcp.gemspec | 6 +++++- spec/mail_mcp/app_spec.rb | 31 ++++++++++++++++++++++++++++++- 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 3afa959..1268290 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,7 +6,7 @@ PATH base64 (~> 0.2) json-jwt (~> 1.16) mail (~> 2.8) - mcp (~> 0.14) + mcp (~> 0.25.0) puma (~> 6.0) rack (~> 3.0) securerandom @@ -73,6 +73,7 @@ GEM faraday (>= 1, < 3) faraday-net_http (3.4.4) net-http (~> 0.5) + hana (1.3.7) hashdiff (1.2.1) i18n (1.14.8) concurrent-ruby (~> 1.0) @@ -85,9 +86,11 @@ GEM bindata faraday (~> 2.0) faraday-follow_redirects - json-schema (6.2.0) - addressable (~> 2.8) - bigdecimal (>= 3.1, < 5) + json_schemer (2.5.0) + bigdecimal + hana (~> 1.3) + regexp_parser (~> 2.0) + simpleidn (~> 0.2) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) @@ -97,8 +100,8 @@ GEM net-imap net-pop net-smtp - mcp (0.14.0) - json-schema (>= 4.1) + mcp (0.25.0) + json_schemer (>= 2.4) mini_mime (1.1.5) minitest (6.0.5) drb (~> 2.0) @@ -182,6 +185,7 @@ GEM simplecov (~> 0.19) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + simpleidn (0.2.3) sinatra (4.2.1) logger (>= 1.6.0) mustermann (~> 3.0) diff --git a/lib/mail_mcp/app.rb b/lib/mail_mcp/app.rb index c5b20b2..e827fbd 100644 --- a/lib/mail_mcp/app.rb +++ b/lib/mail_mcp/app.rb @@ -37,7 +37,11 @@ class App < Sinatra::Base tools: MCP_TOOLS, server_context: server_context ) - transport = MCP::Server::Transports::StreamableHTTPTransport.new(mcp_server, stateless: true) + transport = MCP::Server::Transports::StreamableHTTPTransport.new( + mcp_server, + stateless: true, + allowed_hosts: allowed_mcp_hosts + ) status_code, resp_headers, body = transport.call(env) halt status_code, resp_headers, body end @@ -220,6 +224,13 @@ def base_url ENV.fetch("BASE_URL") end + # The MCP transport's DNS-rebinding protection only accepts loopback `Host` values + # out of the box. This server is reached at its public hostname, so allow-list it — + # a bare host name matches any port. + def allowed_mcp_hosts + [URI.parse(base_url).host] + end + def decode_client_id!(client_id) JwtService.decode_client_id(client_id.to_s) rescue JwtService::Error => e diff --git a/mail_mcp.gemspec b/mail_mcp.gemspec index b62f0db..0b08d93 100644 --- a/mail_mcp.gemspec +++ b/mail_mcp.gemspec @@ -25,7 +25,11 @@ Gem::Specification.new do |spec| spec.add_dependency "base64", "~> 0.2" spec.add_dependency "json-jwt", "~> 1.16" spec.add_dependency "mail", "~> 2.8" - spec.add_dependency "mcp", "~> 0.14" + # Pinned to a single minor: this gem is installed directly (`gem install mail_mcp`), + # so its Gemfile.lock is bypassed and any mcp release would otherwise be picked up at + # install time. 0.23 added the DNS-rebinding Host check plus the `allowed_hosts:` + # option needed to accept our public hostname; earlier versions reject that option. + spec.add_dependency "mcp", "~> 0.25.0" spec.add_dependency "puma", "~> 6.0" spec.add_dependency "rack", "~> 3.0" spec.add_dependency "securerandom" diff --git a/spec/mail_mcp/app_spec.rb b/spec/mail_mcp/app_spec.rb index 85b947e..fe0fe85 100644 --- a/spec/mail_mcp/app_spec.rb +++ b/spec/mail_mcp/app_spec.rb @@ -181,11 +181,14 @@ def issue_code MailMCP::JwtService.issue(creds) end + let(:mcp_host) { URI.parse(ENV.fetch("BASE_URL")).host } + let(:mcp_headers) do { "CONTENT_TYPE" => "application/json", "HTTP_ACCEPT" => "application/json, text/event-stream", - "HTTP_AUTHORIZATION" => "Bearer #{access_token}" + "HTTP_AUTHORIZATION" => "Bearer #{access_token}", + "HTTP_HOST" => mcp_host } end @@ -208,6 +211,32 @@ def mcp_request(method, params = {}, id: 1) ]) end + it "dispatches tools/call to the tool using credentials from the bearer token" do + imap_client = instance_spy(MailMCP::ImapClient) + allow(MailMCP::ImapClient).to receive(:connect).and_yield(imap_client) + allow(imap_client).to receive(:list_mailboxes).and_return(%w[INBOX Sent]) + + post "/mcp", mcp_request("tools/call", { name: "list_mailboxes", arguments: {} }), mcp_headers + + expect(last_response.status).to eq(200) + result = JSON.parse(last_response.body).fetch("result") + expect(result["isError"]).to be(false) + expect(result.dig("content", 0, "text")).to include("INBOX", "Sent") + end + + it "accepts the public host from BASE_URL, including with an explicit port" do + post "/mcp", mcp_request("tools/list"), mcp_headers.merge("HTTP_HOST" => "#{mcp_host}:8443") + + expect(last_response.status).to eq(200) + end + + it "rejects a Host header that is not the public host (DNS rebinding)" do + post "/mcp", mcp_request("tools/list"), mcp_headers.merge("HTTP_HOST" => "evil.example.com") + + expect(last_response.status).to eq(403) + expect(last_response.body).to include("Invalid Host header") + end + it "returns 401 for an invalid Bearer token" do post "/mcp", mcp_request("tools/list"), mcp_headers.merge("HTTP_AUTHORIZATION" => "Bearer invalid.token.here")