Skip to content

fix(mcp): validate the Host header, not only Origin (#50) - #55

Merged
jrosskopf merged 2 commits into
mainfrom
fix/50-host-validation
Sep 1, 2026
Merged

fix(mcp): validate the Host header, not only Origin (#50)#55
jrosskopf merged 2 commits into
mainfrom
fix/50-host-validation

Conversation

@jrosskopf

@jrosskopf jrosskopf commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes nothing on its own — see the note at the bottom about #50.

The gap

PR #45 removed the wildcard Access-Control-Allow-Origin and added Origin validation, and that part works: Origin: https://evil.example is answered 403. The comment on #50 argues that Host must be validated too, and it is right. Measured against the released build:

POST /mcp  Host: rebind.evil.example:8192  Origin: http://rebind.evil.example:8192  → 200
POST /mcp  Host: rebind.evil.example:8192  (no Origin)                              → 200

Both are what DNS rebinding looks like once evil.example points rebind.evil.example at 127.0.0.1. The browser considers the call same-origin, so the first request passes ClassifyOrigin's same-origin rule — which proves nothing, because after rebinding the attacker owns both halves of that comparison — and the second passes the no-Origin rule. Behind the endpoint sit adt_write_source, adt_delete_object, adt_activate, adt_release_transport, bw_delete_object.

What this does

ClassifyHost classifies the Host header on its own, checked before Origin:

Host Verdict
absent NoHost — browsers always send one
localhost, 127.0.0.1, [::1] Loopback
any IP literal (192.168.1.5, [fe80::1]) IpLiteral — an IP address has no DNS name to rebind
the address bound, or named by --allowed-hosts Allowlisted
--allowed-hosts '*' Wildcard
any other DNS name Unrecognised

--allowed-hosts <list> on both mcp --http and catalog webui. The IpLiteral rule is what keeps --host 0.0.0.0 reachable at a LAN address with no configuration.

Warn-only by default

Passing --allowed-hosts is what turns refusal on. Without it an Unrecognised Host is still served, with one warning per distinct host on stderr (deduplicated, so a scripted attack cannot flood the log). A deployment reached through a DNS name or a reverse proxy therefore does not break on upgrade.

The trade-off to be explicit about: in the default configuration the rebinding path above is still open. That is why this PR does not close #50 — closing it would mean flipping the default to enforcing, which is a separate decision.

Verified live

== default (warn-only) ==                  == with --allowed-hosts mcp.internal.example ==
rebound Host + matching Origin   200       rebound Host + matching Origin   403
rebound Host, no Origin          200       rebound Host, no Origin          403
loopback                         200       loopback                         200
                                           LAN IP literal                   200
                                           the configured name              200

Plus: 1363 unit tests pass (13 new), and scripts/ci/webui_smoke.py passes — the embedded web UI is same-origin on loopback, so enforcement costs it nothing.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

jrosskopf and others added 2 commits September 1, 2026 03:49
Origin validation (#45, shipped in v2026.08.31) does not stop DNS
rebinding, and the comment on #50 is right about why. Once evil.example
makes rebind.evil.example resolve to 127.0.0.1, the browser treats the
call as same-origin: the request carries Host: rebind.evil.example and
either a matching Origin or none at all. Measured against that build,
both shapes were answered 200 — the first passing ClassifyOrigin's
same-origin rule, which proves nothing when the attacker owns both sides
of the comparison, the second passing the no-Origin rule.

The Host header is the half an attacker cannot launder, so it is now
classified on its own, before Origin. Loopback names and IP literals
pass unconditionally (an IP address has no name to rebind, which is what
keeps --mcp-host 0.0.0.0 reachable at a LAN address with no config), as
does the address bound and anything named by the new --allowed-hosts.

Passing --allowed-hosts is what turns refusal on. Without it an
unrecognised Host is still served, with one warning per distinct host —
so a deployment reached through a DNS name or a reverse proxy does not
break on upgrade. That leaves the rebinding path open by default, which
is a deliberate trade-off and the reason #50 does not close here.
TestPort()+30..32 already belong to the HTTP status-conformance tests, so
the new cases were binding ports a neighbour had just used. Windows CI
crashed in a nearby test because of it.
@jrosskopf
jrosskopf merged commit 6c2b8b8 into main Sep 1, 2026
5 checks passed
@jrosskopf
jrosskopf deleted the fix/50-host-validation branch September 1, 2026 02:44
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.

security: MCP HTTP transport sets Access-Control-Allow-Origin: * with no Origin validation

1 participant