From 520d07d745c70b272fce5e4a1875f1249106cc52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Mon, 27 Jul 2026 17:12:25 -0300 Subject: [PATCH] [Bug Fix] MCP: allow rubyui.com host in DNS-rebinding protection StreamableHTTPTransport's DNS-rebinding protection only allows loopback Host headers (127.0.0.1, ::1, localhost) by default, so every request to https://rubyui.com/mcp was rejected with "Forbidden: Invalid Host header". RackApp never passed allowed_hosts:, so production traffic never had a chance to pass validation. Pass allowed_hosts: with rubyui.com and www.rubyui.com (overridable via RUBY_UI_MCP_ALLOWED_HOSTS), and add rack_app_test.rb covering both allowed hosts, the loopback default, and that an unrelated host is still rejected. Fixes #481 --- mcp/lib/ruby_ui/mcp/rack_app.rb | 18 ++++++++- mcp/test/rack_app_test.rb | 65 +++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 mcp/test/rack_app_test.rb diff --git a/mcp/lib/ruby_ui/mcp/rack_app.rb b/mcp/lib/ruby_ui/mcp/rack_app.rb index c9211170f..b793457c4 100644 --- a/mcp/lib/ruby_ui/mcp/rack_app.rb +++ b/mcp/lib/ruby_ui/mcp/rack_app.rb @@ -6,13 +6,29 @@ module RubyUI module MCP class RackApp + # StreamableHTTPTransport's DNS-rebinding protection validates the `Host` header + # against an allow list that defaults to loopback only (127.0.0.1, ::1, localhost), + # so without this every request to the production site rejects with + # "Forbidden: Invalid Host header". Extend via RUBY_UI_MCP_ALLOWED_HOSTS + # (comma-separated) for any additional hostnames the site is served from. + DEFAULT_ALLOWED_HOSTS = ["rubyui.com", "www.rubyui.com"].freeze + def self.call(env) (@instance ||= new).call(env) end + def self.allowed_hosts + extra = ENV["RUBY_UI_MCP_ALLOWED_HOSTS"].to_s.split(",").map(&:strip).reject(&:empty?) + (DEFAULT_ALLOWED_HOSTS + extra).uniq + end + def initialize(registry: RubyUI::MCP.registry) server = RubyUI::MCP::Server.build(registry: registry) - @transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) + @transport = ::MCP::Server::Transports::StreamableHTTPTransport.new( + server, + stateless: true, + allowed_hosts: self.class.allowed_hosts + ) end def call(env) diff --git a/mcp/test/rack_app_test.rb b/mcp/test/rack_app_test.rb new file mode 100644 index 000000000..31c593049 --- /dev/null +++ b/mcp/test/rack_app_test.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "test_helper" +require "rack/mock_request" +require "ruby_ui/mcp/rack_app" + +class RackAppTest < Minitest::Test + def setup + registry = RubyUI::MCP::Registry.load(TestSupport::FIXTURE_PATH) + @app = RubyUI::MCP::RackApp.new(registry: registry) + end + + def test_allows_production_host + response = get("rubyui.com") + refute_invalid_host(response) + end + + def test_allows_www_production_host + response = get("www.rubyui.com") + refute_invalid_host(response) + end + + def test_allows_loopback_host + response = get("localhost") + refute_invalid_host(response) + end + + def test_rejects_dns_rebinding_host + response = get("evil.example.com") + assert_equal 403, response.status + assert_includes response.body, "Invalid Host header" + end + + def test_default_allowed_hosts_are_exactly_production_hosts + assert_equal ["rubyui.com", "www.rubyui.com"], RubyUI::MCP::RackApp::DEFAULT_ALLOWED_HOSTS + end + + def test_allowed_hosts_extends_via_env_var + with_env("RUBY_UI_MCP_ALLOWED_HOSTS" => "staging.rubyui.com, other.example.com") do + assert_equal( + ["rubyui.com", "www.rubyui.com", "staging.rubyui.com", "other.example.com"], + RubyUI::MCP::RackApp.allowed_hosts + ) + end + end + + private + + def get(host) + Rack::MockRequest.new(@app).get("/", "HTTP_HOST" => host) + end + + def refute_invalid_host(response) + refute_equal 403, response.status + refute_includes response.body, "Invalid Host header" + end + + def with_env(vars) + previous = vars.keys.to_h { |k| [k, ENV[k]] } + vars.each { |k, v| ENV[k] = v } + yield + ensure + previous.each { |k, v| ENV[k] = v } + end +end