Skip to content

fix(mcp): widen transport_security to match --host - #1737

Merged
RaghavChamadiya merged 2 commits into
repowise-dev:mainfrom
nathanpride:fix/mcp-remote-host-connectivity
Aug 24, 2026
Merged

RaghavChamadiya merged 2 commits into
repowise-dev:mainfrom
nathanpride:fix/mcp-remote-host-connectivity

Conversation

@nathanpride

Copy link
Copy Markdown
Contributor

FastMCP builds its module-level instance with no host= argument, at import time, before any CLI --host is known, so it auto-bakes a DNS-rebinding Host allowlist scoped to loopback only. run_mcp() later rebinds the socket via mcp.settings.host, but never updated the allowlist to match, so every request to a non-loopback --host failed Host validation with 421 Misdirected Request no matter what --host was given.

_configure_transport_security() rebuilds the allowlist around the actual bind host (or disables the now-unsatisfiable Host check for a wildcard bind, where nothing else currently gates access).

Summary

  • Widen the MCP HTTP/SSE transport's DNS-rebinding Host allowlist to match --host, instead of leaving it frozen on loopback (fixes 421 Misdirected Request on any non-default --host)

Related Issues

Fixes #1736
Related to #1400 (same function — run_mcp in _server.py — but a distinct concern: #1400 is about missing authentication on a wide-open bind, which this PR does not address)

Test Plan

  • Tests pass (pytest) — uv run pytest tests/unit/server/test_mcp_transport_security.py tests/unit/cli/test_mcp_transport.py, 28 passed
  • Lint passes (ruff check .)
  • Web build passes (npm run build) (if frontend changes) — n/a, no frontend changes
  • Manual: repowise mcp --transport streamable-http --host <non-loopback IP>, then a request with a matching Host header — 200 OK / clean initialize response, no 421/Invalid Host header (verified against both a wildcard 0.0.0.0 bind and a concrete interface IP)

Checklist

  • My code follows the project's code style
  • I have added tests for new functionality
  • All existing tests still pass
  • I have updated documentation if needed

FastMCP builds its module-level instance with no host= argument, at import
time, before any CLI --host is known, so it auto-bakes a DNS-rebinding Host
allowlist scoped to loopback only. run_mcp() later rebinds the socket via
mcp.settings.host, but never updated the allowlist to match, so every
request to a non-loopback --host failed Host validation with
421 Misdirected Request no matter what --host was given.

_configure_transport_security() rebuilds the allowlist around the actual
bind host (or disables the now-unsatisfiable Host check for a wildcard
bind, where nothing else currently gates access).
@repowise-bot

repowise-bot Bot commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

✅ Health of changed files: 1.0 → 2.6 (+1.6)
⚠️ Change risk: moderate, riskier than 48% of this repo's commits.

📋 At a glance
1 hotspot touched · 2 new findings introduced · 1 file with recent fix history. Scoped to packages.

✅ Health gate: passed

📌 Before you merge

  • Run .../cli/test_embedder_key_resolution.py, .../cli/test_mcp_transport.py, .../mcp/test_config.py: they import the changed files
🔎 More signals (2)

🗺️ Change map

flowchart LR
  subgraph PR ["Changed in this PR (1 with dependents)"]
    f_packages_server_src_repowise_server_mcp_server__server_py[".../mcp_server/_server.py 🔥"]:::changed
  end
  f_packages_cli_src_repowise_cli_helpers_py[".../cli/helpers.py"]
  f_packages_server_src_repowise_server_mcp_server__server_py --> f_packages_cli_src_repowise_cli_helpers_py
  f_packages_core_src_repowise_core_workspace_update_py[".../workspace/update.py"]
  f_packages_server_src_repowise_server_mcp_server__server_py --> f_packages_core_src_repowise_core_workspace_update_py
  f_packages_server_src_repowise_server_app_py[".../server/app.py"]
  f_packages_server_src_repowise_server_mcp_server__server_py --> f_packages_server_src_repowise_server_app_py
  f_packages_server_src_repowise_server_mcp_server___init___py[".../mcp_server/__init__.py"]
  f_packages_server_src_repowise_server_mcp_server__server_py --> f_packages_server_src_repowise_server_mcp_server___init___py
  t_tests_unit_cli_test_embedder_key_resolution_py(["✅ .../cli/test_embedder_key_resolution.py"]):::guard
  t_tests_unit_cli_test_embedder_key_resolution_py -.-> f_packages_server_src_repowise_server_mcp_server__server_py
  classDef changed fill:#dbeafe,stroke:#1d4ed8,color:#1e3a5f
  classDef warn fill:#fef3c7,stroke:#b45309,color:#78350f
  classDef guard fill:#dcfce7,stroke:#15803d,color:#14532d
Loading

Solid arrows: code that imports the changed files (4 direct dependents, from the last indexed snapshot). Dashed: history/tests.

🔥 Hotspot touched (1)

  • .../mcp_server/_server.py: 23 commits/90d, 9 dependents · primary owner: Raghav Chamadiya (90%)

👀 Suggested reviewers @RaghavChamadiya


📊 See the full report for this PR
Your repo map with this PR's blast radius lit up, every caller of the contracts it changes, and health before and after. No sign-in. · ⭐ Star Repowise · 📥 Install bot · Silence on a single PR with [skip repowise] in the title · Per-repo toggle on repowise.dev/settings?tab=bot · Updated 2026-08-22 14:43 UTC (since the last push: health 2.2 to 2.6)

@sloemo01

Copy link
Copy Markdown
Contributor

The fix is right for the common LAN/hostname case, and the tests are genuinely good — exercising the real TransportSecurityMiddleware rather than just the settings object is the right call.

One real gap worth handling or explicitly scoping out: a concrete IPv6 --host (e.g. 2001:db8::1) still breaks. The SDK's _validate_host matches by host.startswith(base_host + ":"), and a client connecting to an IPv6 literal sends a bracketed Host header — Host: [2001:db8::1]:7338 — which never starts with 2001:db8::1:. So a non-wildcard IPv6 bind still gets 421, which contradicts "widen the allowlist to match --host". That'd need the bracketed [host]:* form in the allowlist for IPv6, and the parametrized middleware test only covers IPv4 today, so it wouldn't catch it.

Also worth a sentence in the PR body: disabling the Host check for 0.0.0.0/:: turns a previously safe-but-unreachable wildcard bind into an open one (gated only by the existing API-key warning). You've already noted #1400 covers the missing auth on wide binds — this PR is what actually unlocks that surface, so it'd help to state that the disable is intended to ride on the warning until auth lands, since the two now couple in effect even if not in scope.

Minor: allowed_origins inherits the same prefix startswith matching, so http://172.21.12.48:* also accepts something like http://172.21.12.48:8080.evil.com. Low severity (Origin validation sits on top of Host, and only matters for clients sending an Origin), but it's inherited looseness.

TransportSecurityMiddleware matches by host.startswith(base_host + ":"),
and a client connecting to an IPv6 literal sends a bracketed Host header
([2001:db8::1]:7338). The allowlist entry has to be shaped the same way -
a bare "2001:db8::1:*" pattern never matches that header, so a concrete
IPv6 --host still 421'd despite the fix. _bracket_if_ipv6() brackets the
host before it goes into allowed_hosts/allowed_origins.

Also documents two review-flagged, out-of-scope points inline rather than
leaving them implicit: disabling the Host check for a wildcard bind rides
on the existing unauthenticated-bind warning until #1400 lands auth, and
allowed_origins inherits the same startswith prefix looseness as
allowed_hosts from the SDK itself (pre-existing, not introduced here).
@nathanpride

Copy link
Copy Markdown
Contributor Author

Thanks for the review

  1. Fixed in 5173206: _bracket_if_ipv6() brackets a concrete IPv6 --host before it
    goes into the allowlist, so 2001:db8::1 now produces [2001:db8::1]:*, matching
    the bracketed Host header a client actually sends. Added a parametrized test against
    the real TransportSecurityMiddleware for this (the existing one was IPv4-only, as
    you flagged) plus a unit test on the bracketing helper itself.

  2. Added a paragraph to _configure_transport_security's docstring and to the PR
    description: disabling the Host check for 0.0.0.0/:: does turn a previously
    safe-but-unreachable wildcard bind into a reachable one, gated only by the existing
    API-key warning, until [Bug] MCP HTTP/SSE transports have no authentication and FASTMCP_HOST can expose them #1400 adds real enforcement. Agreed that's worth being
    explicit about even though it's not this PR's scope.

  3. Confirmed by reading the SDK source directly: _validate_origin uses the identical
    startswith(base + ":") match as _validate_host, and FastMCP's own loopback defaults
    have the same looseness (http://127.0.0.1:* also matches an ...evil.com suffix
    today). Leaving as-is since it's inherited/pre-existing rather than something this
    PR introduces — noted inline in the code so it's not silently reproduced without
    acknowledgment. Happy to file a separate issue against the SDK-shaped behavior if
    you'd rather track it than just comment it.

@sloemo01

sloemo01 commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

Update to the earlier note on the _validate_origin looseness: I routed this through the SDK's proper private channel per their SECURITY.md (they ask that security findings not be filed as public issues). Filed as a private advisory against mcp, status Triage, severity Moderate:

https://github.com/modelcontextprotocol/python-sdk/security/advisories/GHSA-4w35-279q-9x9p

Short version for the record: _validate_host and _validate_origin in src/mcp/server/transport_security.py both match with startswith(base + ":"), so 127.0.0.1:evil.com (or 172.21.12.48:8080.evil.com) passes the allowlist even though it is not that host — a bypass of the DNS-rebinding/Origin protection. Same looseness in the loopback defaults FastMCP bakes in, so this is inherited behavior, not something your PR introduces — exactly as you noted. Your inline code comment already acknowledges it, so nothing further is needed on the repowise side; the advisory just makes it trackable upstream and can drive a dependency bump when a fix lands. (Earlier I filed it as a public issue; I closed that in favor of the private advisory to comply with the SDK's policy.)

@sloemo01

Copy link
Copy Markdown
Contributor

Thanks for flagging this and offering to track it — that saved me the legwork. I went ahead and filed it upstream as a private advisory (their SECURITY.md prefers that over a public issue), so it's on the SDK maintainers' radar now: https://github.com/modelcontextprotocol/python-sdk/security/advisories/GHSA-4w35-279q-9x9p — status Triage, Moderate.

It's inherited/FastMCP-default behavior as you noted, so nothing further is needed on this PR; when a fix lands upstream we'll just bump the dep. Appreciate the thorough response to the review.

@RaghavChamadiya RaghavChamadiya left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @nathanpride. The diagnosis is the valuable part: FastMCP builds mcp.settings.transport_security at import time, before any CLI --host exists, so it bakes in a loopback-only allowlist. run_mcp then rebinds the socket through mcp.settings.host and nothing ever revisits the allowlist. That is why --host produced a 421 for every value, which reads like a networking problem and is not one.

_bracket_if_ipv6 is the detail that shows you read the SDK rather than guessed. TransportSecurityMiddleware matches on host.startswith(base + ":"), so a bare 2001:db8::1 can never match the bracketed [2001:db8::1]:7338 a client actually sends, and the "any colon means IPv6, because hostnames and IPv4 never contain one" reasoning is right and worth having written down.

On the wildcard case, I want to say plainly that I read it as a deliberate trade and I agree with it. Disabling DNS-rebinding protection sounds like a downgrade, but no single Host value can match a 0.0.0.0 bind, so the previous behaviour was not protection, it was a permanent 421. Nothing was reachable to protect. Turning it off makes the bind do what the operator asked and leaves the startup security warning as the gate until #1400 lands. Your docstring says exactly that, which is why it does not need a reviewer to reconstruct it.

Keeping loopback in the allowlist for a non-loopback bind is the right default too. An SSH tunnel or a client on the same box arrives as 127.0.0.1 and would otherwise break the moment someone set --host.

Full matrix green. Merging. This closes #1736.

@RaghavChamadiya
RaghavChamadiya merged commit ef36bec into repowise-dev:main Aug 24, 2026
9 checks passed
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 makes the MCP server unreachable — 421 Misdirected Request

3 participants