Skip to content

Make Origin validation opt-in in the app factories (keep Host validation on) #2852

Description

@daoluc

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 Host on 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 CrossOriginProtection means no check.
  • The enableoriginverification setting, which could bring the old default back, was removed in 1.8.0.
  • docs/rough_edges.md says 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 when allowedOrigins is passed. They no longer arm localhostOriginValidation() by default.
  • Host validation defaults are unchanged.
  • The Origin helpers stay exported, and so does the allowedOrigins option. Operators who want the 2.0 behaviour pass allowedOrigins: localhostAllowedOrigins().
  • Update the docs to describe allowedOrigins as 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 any Origin with that scheme, for example chrome-extension://* or moz-extension://*.
  • Existing hostname entries keep working as today.
  • http://* and https://* throw at construction time, because they would allow every website. Leaving allowedOrigins unset already does that.
  • null and 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

  1. added
    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes
    on Sep 24, 2026
  2. iamhuman-cheolheelee commented on Sep 24, 2026

    @iamhuman-cheolheelee

    Checked this against main (7f7a94c). The default Origin arming is the else if branch 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: arm originValidation(allowedOrigins) only when allowedOrigins is passed, and leave localhostHostValidation() untouched.

    Before anyone writes code, a few things a maintainer should confirm:

    • Behavior change for 2.x users. Anyone on a localhost bind who relies on the implicit Origin 403 (e.g. servers using cookie or ambient-credential auth) silently loses it. A changelog note, plus a doc line pointing at localhostOriginValidation() for opting back in, seems necessary.
    • Rebinding is still covered. With Host validation on by default, a rebound request carries Host: attacker.example and 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 loopback Host. The Origin check is the only thing that stops that, so it's what we'd be giving up for servers without auth.
    • Interaction with createMcpExpressApp / createMcpHonoApp: non-loopback host silently skips Host/Origin checks (only 0.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 when allowedOrigins is set, and the doc/changelog updates.

  3. felixweinberger commented on Sep 30, 2026

    @felixweinberger
    Contributor

    A server can now admit MCP clients that run as a browser extension: allowedOrigins accepts entries of the form <scheme>://*, such as moz-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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions