Repository navigation
Make Origin validation opt-in in the app factories (keep Host validation on) #2852
Description
Problem
Since 2.0 (#2286), createMcpExpressApp, createMcpHonoApp and createMcpFastifyApp turn on Origin validation by default for localhost-class binds, alongside the existing Host validation.
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 default doesn't add rebinding protection. The DNS-rebinding advisory (GHSA-w48q-cv73-mx4w) is closed by the Host check, and the v2 docs already say that on a localhost bind the Host check is what stops rebinding. 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 and SDK comparison are in modelcontextprotocol/modelcontextprotocol#3370.
Prior art: Go SDK
The Go SDK made exactly this change:
- DNS-rebinding protection (checking
Hoston loopback) has been on by default since 1.4.0. - Origin protection was turned on by default in 1.4.1 and turned back off in 1.6.0 (Zod version compatibility issue with MCP SDK #906). Since then, a nil
CrossOriginProtectionmeans no check. - The
enableoriginverificationsetting, which could bring the old default back, was removed in 1.8.0. docs/rough_edges.mdsays the option "should not have been part of the SDK API" because cross-origin protection "is a general HTTP concern, not specific to MCP".
Proposal
- The app factories apply
originValidation(allowedOrigins)only whenallowedOriginsis passed. They no longer armlocalhostOriginValidation()by default. - Host validation defaults are unchanged.
- The Origin helpers stay exported, and so does the
allowedOriginsoption. Operators who want the 2.0 behaviour passallowedOrigins: localhostAllowedOrigins(). - Update the docs to describe
allowedOriginsas the CSRF control and Host validation as the DNS-rebinding control. - v1.x needs no change, since its factory already defaults to Host-only.
This doesn't reopen CSRF on unauthenticated localhost servers. The transport already returns 415 for any POST whose Content-Type isn't application/json, and a cross-site JSON POST forces a CORS preflight that the server doesn't approve. I'd add a test that pins this.
Compatibility
This relaxes a 2.x default. A localhost server that relied on the automatic Origin check would now accept cross-origin requests that pass the Host check and the Content-Type check. Should this ship in a minor release with a changeset note, or wait for the next major? I'll go with the maintainers' call.
Further proposals
1. Scheme wildcards in allowedOrigins. 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, whose moz-extension://<uuid> origin differs on every install. Proposal:
- An entry of the form
<scheme>://*matches anyOriginwith that scheme, for examplechrome-extension://*ormoz-extension://*. - Existing hostname entries keep working as today.
http://*andhttps://*throw at construction time, because they would allow every website. LeavingallowedOriginsunset already does that.nulland unparseable Origins are still rejected.
2. A machine-readable reason in the 403 body. validateHostHeader and validateOriginHeader already compute an errorCode (missing_host, invalid_host_header, invalid_host, invalid_origin_header, invalid_origin), but the response only carries a free-text message. A client can't tell a Host or Origin rejection from an auth failure, so it can't tell the user that signing in again won't help. Proposal: include the code in the JSON-RPC error, keeping the status and message unchanged:
{ "jsonrpc": "2.0", "error": { "code": -32000, "message": "Invalid Origin: evil.example", "data": { "reason": "invalid_origin" } }, "id": null }Disclosure: drafted with Claude Code;
Activity
iamhuman-cheolheelee commentedon Sep 24, 2026
Checked this against
main(7f7a94c). The default Origin arming is theelse ifbranch right after the Host ladder in all three factories (express.ts,hono.ts,fastify.ts). The Host defaults are a separate block, so the proposed change fits in a few lines per factory: armoriginValidation(allowedOrigins)only whenallowedOriginsis passed, and leavelocalhostHostValidation()untouched.Before anyone writes code, a few things a maintainer should confirm:
localhostOriginValidation()for opting back in, seems necessary.Host: attacker.exampleand still gets rejected, so the GHSA-w48q-cv73-mx4w fix doesn't depend on the Origin check. The one gap is a malicious page on another localhost port, which sends a matching loopbackHost. The Origin check is the only thing that stops that, so it's what we'd be giving up for servers without auth.createMcpExpressApp/createMcpHonoApp: non-loopbackhostsilently skips Host/Origin checks (only0.0.0.0/::warn) | #2843, which touches the same code and argues for stricter defaults on non-loopback binds.If a maintainer agrees with the direction (or wants it in the spec discussion in modelcontextprotocol/modelcontextprotocol#3370 first), I'm happy to take it: the change across all three factories, tests asserting a
chrome-extension://Origin passes by default and is rejected whenallowedOriginsis set, and the doc/changelog updates.felixweinberger commentedon Sep 30, 2026
A server can now admit MCP clients that run as a browser extension:
allowedOriginsaccepts entries of the form<scheme>://*, such asmoz-extension://*, and a Chrome extension's ID can be listed as before (#2907). The default stays as it is for now. With the Origin check off, a local server without authentication that has CORS enabled would answer a page on any site. We'll decide the default together with the spec change in modelcontextprotocol/modelcontextprotocol#3370.