Skip to content

fix(server): trusted_hosts allowlist for reverse-proxy Host validation#899

Merged
Dumbris merged 7 commits into
mainfrom
fix/898-trusted-hosts
Jul 23, 2026
Merged

fix(server): trusted_hosts allowlist for reverse-proxy Host validation#899
Dumbris merged 7 commits into
mainfrom
fix/898-trusted-hosts

Conversation

@Dumbris

@Dumbris Dumbris commented Jul 23, 2026

Copy link
Copy Markdown
Member

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 loopback Host header — and mcpproxy exposed no escape hatch. The only workaround was spoofing Host to the bind address at the proxy layer.

Fix

  • trusted_hosts config key (env MCPPROXY_TRUSTED_HOSTS, hot-reloadable): non-loopback Host values 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.
  • mcp-go's built-in check disabled on all five StreamableHTTPServer mounts, replaced by hostValidationMiddleware with identical default semantics (non-loopback listeners and unix-socket/tray connections never rejected).
  • Origin validation (MCP spec MUST): a present Origin on a loopback listener must be a well-formed http(s)/ws(s) origin with a loopback or trusted host, else 403. Absent Origin (all non-browser clients) always passes — cannot re-break proxied deployments.
  • Trailing-dot FQDNs (example.com.) match their undotted entries.
  • Docs: new Operations → Reverse Proxy Deployment guide (nginx + Caddy, auth guidance) wired into the docs.mcpproxy.app sidebar — the published site previously had no reachable page on this (the reporter's exact complaint) — plus config-file/env-var reference updates and a disambiguation note on the 403 error page.

Design validation

A 105-agent deep-research pass over the ecosystem confirmed the allowlist is the idiomatic solution (MCP Python SDK allowed_hosts, TS SDK allowedHosts, 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

  • Unit: 40+ case matrix (Host/Origin, IPv6, ports, case, wildcards, trailing dots, unix-socket path) + config load/env override + hot-reload change detection.
  • Local E2E: exact issue scenario 403→200 with trusted_hosts, untrusted hosts/origins still 403, hot-reload live.
  • ./scripts/test-api-e2e.sh green; go test -race (server/config/runtime) green; golangci-lint v2 clean; swagger regenerated; Docusaurus build clean with broken-link check.
  • Codex cross-model review: 5 rounds, final verdict LGTM (fixed along the way: hot-reload change reporting, origin shape strictness, trailing-dot FQDNs, port validation, docs precision).

🤖 Generated with Claude Code

#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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 23, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

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

View logs

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>
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown

📦 Build Artifacts

Workflow Run: View Run
Branch: fix/898-trusted-hosts

Available Artifacts

  • archive-darwin-amd64 (28 MB)
  • archive-darwin-arm64 (25 MB)
  • archive-linux-amd64 (16 MB)
  • archive-linux-arm64 (15 MB)
  • archive-windows-amd64 (28 MB)
  • archive-windows-arm64 (25 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (22 MB)
  • installer-dmg-darwin-arm64 (20 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 30032017670 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

@codecov-commenter

codecov-commenter commented Jul 23, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 97.00000% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/server/host_validation.go 96.10% 2 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@Dumbris

Dumbris commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

CI note: the two red Analyze (go/javascript-typescript) jobs fail with a CodeQL infra error — Loaded a configuration file for version '4.37.3', but running version '4.37.0/1' (bundle/tool-cache version mismatch, fails in ~20s before any analysis). The same jobs are failing on the last 3 pushes to main (e.g. run 29892885137), so this is pre-existing and unrelated to this change. All other checks are green, including E2E, race tests, and swagger-verify.

Dumbris and others added 5 commits July 23, 2026 20:43
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>
@Dumbris
Dumbris merged commit 39e88cd into main Jul 23, 2026
40 checks passed
@Dumbris
Dumbris deleted the fix/898-trusted-hosts branch July 23, 2026 18:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Host header validation breaks reverse proxy setups without explicit override (403 Forbidden: invalid Host header)

2 participants