Repository navigation
Make Origin validation opt-in in the app factories (keep Host validation on for localhost) #3576
Description
Description
When enable_dns_rebinding_protection=True, TransportSecurityMiddleware checks Host and Origin together. It can't do one without the other, and an empty allowed_origins means any request that carries an Origin header is rejected. MCPServer and lowlevel.Server also turn this on by default for localhost binds, with a localhost-only allowed_origins.
This blocks browser-based MCP clients such as extensions. A Chrome extension sends Origin: chrome-extension://<id>. The same request with the same credentials gets 200 without an Origin header and 403 with one. The only fix is for every server operator to allowlist each client by hand.
The Origin check doesn't add rebinding protection. The DNS-rebinding advisory (GHSA-9h52-p55h-vw2f) is closed by the Host check. An Origin check is a CSRF control, and it only matters when the server accepts credentials the browser sends automatically, such as cookies. The full threat model is in modelcontextprotocol/modelcontextprotocol#3370.
The Go SDK made this change already. Host protection on loopback is on by default. Origin protection was on by default in v1.4.1 through v1.5.0 and has been off since v1.6.0. The setting that could restore it was removed in v1.8.0.
Proposal:
- Validate
Originonly whenallowed_originsis non-empty. An empty list means no Origin check, not reject-all. - Drop the default localhost
allowed_originsinMCPServerandlowlevel.Server. - Host validation and its defaults are unchanged. When the Origin check is on,
Origin: nullis still rejected. - Update
docs/run/deploy.mdto describeallowed_originsas the CSRF control andallowed_hostsas the DNS-rebinding control.
This doesn't reopen CSRF on unauthenticated localhost servers. The middleware already returns 400 for any POST whose Content-Type isn't application/json, and a cross-site JSON POST forces a CORS preflight. I'd add a test that pins this.
Compatibility: there are two behaviour changes, and I'd like a maintainer's view on whether a 2.x minor release is OK for them.
- Localhost servers using the defaults would no longer reject cross-origin requests that pass the Host and Content-Type checks.
- Anyone who sets
allowed_hostswithoutallowed_originswould no longer reject every request that carries anOriginheader.
In both cases, setting allowed_origins explicitly restores today's behaviour.
Further proposals
-
Scheme wildcards in
allowed_origins. Once Origin validation is opt-in, an operator who turns it on still has to list every browser-extension client by hand. That's impossible for Firefox, whosemoz-extension://<uuid>origin differs on every install. Proposal:- An entry of the form
"<scheme>://*"matches anyOriginwith that scheme, for example"chrome-extension://*"or"moz-extension://*". - Exact entries and the existing
:*port wildcard keep working as today. "http://*"and"https://*"raise aValueError, because they would allow every website. Leavingallowed_originsempty already does that.- This would build on the fix for DNS rebinding :* allowlist matches Host/Origin suffixes that are not ports #3463 (the
:*suffix bug), since it touches the same matcher.
- An entry of the form
-
A machine-readable reason in the rejection body. Today a Host rejection is
421 Invalid Host headerand an Origin rejection is403 Invalid Origin header, both as plain text. A client can't reliably tell these from an auth failure, so it can't tell the user that signing in again won't help. Proposal: keep the status codes, but return a JSON-RPC error body whosedata.reasoncarries a stable code:{ "jsonrpc": "2.0", "error": { "code": -32000, "message": "Invalid Origin header", "data": { "reason": "invalid_origin" } }, "id": null }The reason values would match the ones the TypeScript SDK already computes internally (
missing_host,invalid_host,invalid_origin_header,invalid_origin), so clients can handle both SDKs the same way.
Disclosure: drafted with Claude Code;
Activity
lmtyy commentedon Sep 28, 2026
I’d be interested in working on the core proposal here.
My understanding is that Host validation is the DNS rebinding protection boundary, while Origin validation serves a separate CSRF-style role. I’d keep the current Host validation behavior unchanged and make Origin validation conditional on a non-empty
allowed_origins.I’d also add regression tests for browser-origin requests, remove the default localhost Origin allowlist where appropriate, and update the deployment/security docs to clarify the distinction between
allowed_hostsandallowed_origins.I’d keep scheme wildcards and machine-readable rejection reasons out of scope for this PR.
If this behavior change is acceptable for the v2 line, I’d be happy to implement it.
Ben-Home commentedon Oct 1, 2026
Your threat-model argument is the part of this that should decide it: the Origin check is a CSRF control, and it only carries weight when the server accepts credentials the browser attaches automatically, such as cookies. For a bearer-token or MCP-session-credential server, an attacker's page cannot set the Authorization header cross-origin without a CORS preflight that the server would have to approve anyway, so Origin is not the load-bearing control. Conflating it with the Host check is what produces the reject-all-when-empty behavior, because the two checks have genuinely different correct defaults.
The Go SDK precedent is strong evidence and worth being explicit about why the sequence went the way it did: Origin protection was default-on for a period, then off, then the setting that could restore it was removed. That reads as a maintainer conclusion rather than drift, and it puts the burden on any counter-argument to explain what changed since. The Host check retained by default is the one that closes GHSA-9h52-p55h-vw2f, which is the actual DNS-rebinding threat.
On the proposal itself, the three parts hold together only if the middle one is accepted, so I'd sequence the argument that way:
allowed_originsmeans no Origin check, not reject-all. The current behavior makes the empty case the most restrictive rather than the least, which is inverted from how every other allowlist in the ecosystem reads. A list that is empty meaning "nothing is allowed" is a defensible convention for an ACL, but not for an optional transport-security knob.MCPServerandlowlevel.Server. The localhost default is what makes the failure hit browser-based clients specifically and immediately, sinceOrigin: chrome-extension://<id>matches nothing in a localhost-only list. Keeping the check available while removing the default is what makes the first change safe.One thing worth pre-empting in the issue, since it's the likely review objection: anyone who wants Origin enforcement for a cookie-bearing deployment still needs a way to opt in. Your first bullet gives them that, because a non-empty list turns the check on. That's a cleaner design than a separate boolean, and it's worth stating explicitly so the change doesn't read as removing the control rather than defaulting it off.
The
component_name_hookdetail is not relevant to this one, but the claim that the same request with the same credentials gets 200 without anOriginheader and 403 with one is the reproduction that makes this unambiguous, and it's good that you led with it.bokelley commentedon Oct 3, 2026
A data point from the other direction. We run a multi-tenant AdCP seller (Embedded Sales Agent, on the
adcpPython SDK) with one subdomain per tenant. The tenant set changes at runtime, andallowed_hostscan't express*.example.com(#2141). So we validate Host in our own tenant-routing middleware and setenable_dns_rebinding_protection=False. On 2.0.0 that also turns off the Origin check (transport_security.py:102-104returns before_validate_origin). To get it back we had to re-implement_validate_originas a separate ASGI middleware (about 70 lines) that copies its matching rules.Could the fix make Origin enforcement depend only on
allowed_origins, and not onenable_dns_rebinding_protection? Concretely:enable_dns_rebinding_protection(or a newvalidate_host) controls the Host check, and a non-emptyallowed_originsturns on the Origin check whatever the Host setting is. That covers your case (Origin off, Host on) and ours (Host off, Origin on). Either way, a server that opts out of one check keeps the other.