fix(server): trusted_hosts allowlist for reverse-proxy Host validation#899
Conversation
#898) mcp-go v0.56.0 (pulled in by the go-deps bump #828, shipped in v0.50+) added DNS-rebinding protection that 403s any request arriving on a loopback listener with a non-loopback Host header. Reverse-proxied deployments (nginx forwarding mcp.example.com -> 127.0.0.1) trip it, and mcpproxy exposed no escape hatch — the only workaround was spoofing Host to the bind address at the proxy layer. Replace mcp-go's built-in check (WithDisableLocalhostProtection on all five StreamableHTTPServer mounts) with hostValidationMiddleware, which keeps identical default semantics — loopback listeners only accept loopback Host headers; non-loopback listeners and unix-socket/tray connections are never rejected — and additionally accepts hosts listed in the new `trusted_hosts` config key (env: MCPPROXY_TRUSTED_HOSTS, comma-separated). Entries match hostnames case-insensitively; an entry without a port matches any port; "*" disables validation. The list is read per request, so config hot-reload applies without a restart. Fixes #898 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploying mcpproxy-docs with
|
| Latest commit: |
63b87eb
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://852b77ed.mcpproxy-docs.pages.dev |
| Branch Preview URL: | https://fix-898-trusted-hosts.mcpproxy-docs.pages.dev |
A lone trusted_hosts edit applied via the API was swallowed as "No
configuration changes detected" (AppliedImmediately=false) even though
the middleware picks the new list up per request. Compare with
slices.Equal so nil vs []string{} (the PATCH omitempty round-trip
artifact) is not a spurious change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
📦 Build ArtifactsWorkflow Run: View Run Available Artifacts
How to DownloadOption 1: GitHub Web UI (easiest)
Option 2: GitHub CLI gh run download 30032017670 --repo smart-mcp-proxy/mcpproxy-go
|
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
CI note: the two red |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…trusted_hosts Deep-research follow-up on #898 closing two verified gaps vs the ecosystem/spec: - Origin validation (MCP spec 2025-11-25 MUST): a request on a loopback listener that carries an Origin header must have a loopback or trusted origin host, else 403. Absent Origin (every non-browser client, all reverse-proxied traffic) always passes, so proxied deployments are unaffected. Mirrors python-sdk's allowed_hosts+allowed_origins pairing. - Leading-dot subdomain wildcard: ".example.com" matches the bare domain and every subdomain (Django ALLOWED_HOSTS / Vite server.allowedHosts / webpack allowedHosts convention). Suffix-collision domains (evilexample.com) stay rejected. - Docs: "*" now carries an explicit DNS-rebinding warning (Vite/webpack precedent); new semantics documented in the reverse-proxy guide and config reference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sion (Codex round 3) - originAllowed now requires a well-formed serialized origin: http(s)/ws(s) scheme, no userinfo/path/query/fragment — rejects "//host", "ftp://host", "https://user@host", "https://host/path". - Trailing-dot FQDNs (example.com.) are DNS-equivalent and now match their undotted trusted_hosts entries, including dot-wildcards. - Docs: reverse proxies forward browser Origin headers as-is; clarify same-origin vs cross-origin browser frontends instead of claiming all proxied traffic is unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Codex round 4) url.Parse tolerates "https://host:" and any digit run as a port; a serialized browser origin cannot carry either, so both are now invalid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes #898
Problem
Since v0.50 (mcp-go v0.55.1 → v0.56.0 bump in #828), running mcpproxy behind a reverse proxy fails with
403 Forbidden: invalid Host header. mcp-go v0.56.0 added DNS-rebinding protection — requests on a loopback listener must carry a loopbackHostheader — and mcpproxy exposed no escape hatch. The only workaround was spoofingHostto the bind address at the proxy layer.Fix
trusted_hostsconfig key (envMCPPROXY_TRUSTED_HOSTS, hot-reloadable): non-loopbackHostvalues accepted on loopback listeners. Case-insensitive; entry without port matches any port, with port exact; leading-dot entry (.example.com) is a subdomain wildcard (Django/Vite convention);"*"disables validation (documented as a DNS-rebinding footgun). Default empty = exact pre-existing protection, no security regression.StreamableHTTPServermounts, replaced byhostValidationMiddlewarewith identical default semantics (non-loopback listeners and unix-socket/tray connections never rejected).Originon a loopback listener must be a well-formed http(s)/ws(s) origin with a loopback or trusted host, else 403. AbsentOrigin(all non-browser clients) always passes — cannot re-break proxied deployments.example.com.) match their undotted entries.Design validation
A 105-agent deep-research pass over the ecosystem confirmed the allowlist is the idiomatic solution (MCP Python SDK
allowed_hosts, TS SDKallowedHosts, Blockscout, Vite/webpack/Django) and that default-empty matches the secure-by-default norm (CVE-2025-66414/66416, CVE-2026-34742). Both Go SDKs offer only a binary disable — this is strictly more granular.Verification
trusted_hosts, untrusted hosts/origins still 403, hot-reload live../scripts/test-api-e2e.shgreen;go test -race(server/config/runtime) green; golangci-lint v2 clean; swagger regenerated; Docusaurus build clean with broken-link check.🤖 Generated with Claude Code