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

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