Skip to content

Origin validation rejects browser-based MCP clients on authenticated remote servers #3370

Description

@daoluc

Origin validation rejects browser-based MCP clients on authenticated remote servers: SDK comparison and threat model

Follow-up to the transports-WG Discord thread with @KURTIS Van Gent and @paul Carleton, who asked for a comparison of SDK behavior and a documented threat model. Posting here because Discord is not a good place for this much detail.

Scope: this analysis covers the TypeScript SDK (v1.29.0 and 2.0.0 GA) and the Python SDK (v2.2.0) only for now. Other SDKs are mentioned only where a public issue or advisory made them relevant, and have not been reviewed.

Disclosure: the SDK source review, test runs and this write-up were prepared with Claude Code; the analysis and conclusions were reviewed by me.

TL;DR

  • The Streamable HTTP spec requires servers to validate a present Origin and return 403 when it is invalid, but it does not define a universal validation policy.
  • The two SDKs reviewed so far, TypeScript and Python, both implement this as an explicit allowlist and both let a request with no Origin through.
  • That creates an interoperability asymmetry for browser-hosted MCP clients: a native request with no Origin succeeds, while an otherwise identical authenticated browser request is rejected because it truthfully supplies its Origin.
  • The DNS-rebinding advisories that motivated these protections specifically concern local HTTP servers without authentication, and current SDK documentation identifies Host validation as the key protection against that rebinding path.
  • The open design question is whether Origin policy should distinguish local or private-network deployments from remote HTTPS servers that authenticate every request with a non-ambient bearer token.

1. The problem

I build a Chrome extension that is an MCP client. It completes OAuth against a hosted third-party MCP server and holds a valid bearer token. Chrome extension requests carry Origin: chrome-extension://<id>. Rewriting that header from the extension is not a portable solution and can conflict with the browser's own request-origin and CORS handling, so in practice the server sees it.

Same request, same valid token Result
No Origin header (curl, native client) 200
Origin: chrome-extension://<id> 403 "Invalid Origin"

The only workaround is to ask each server operator to allowlist each client by hand. That does not scale, and for some clients a stable server-side allowlist is not generally practical: ordinary Firefox extension requests use a per-installation moz-extension://<uuid> origin rather than the developer-facing extension ID.

2. Threat model: why the requirement exists

The DNS-rebinding advisories that motivated the SDK fixes, GHSA-w48q-cv73-mx4w (TypeScript, CVE-2025-66414) and GHSA-9h52-p55h-vw2f (Python, CVE-2025-66416), specifically concern locally exposed HTTP MCP servers running without authentication.

The attack they describe: a malicious page on evil.example loads, then its DNS answer flips to 127.0.0.1. The page fetches http://evil.example:3000/mcp; the browser treats it as same-origin, sends no preflight, and lets the script read the reply. The request reaches the local server with Host: evil.example:3000, and on POST also Origin: http://evil.example:3000. If the server has no authentication, the attacker drives its tools.

Three properties of that attack matter for the discussion:

  1. It runs over plain http://. Over TLS the certificate the server presents does not match evil.example.
  2. It targets a listener the attacker cannot reach directly (loopback, private network). A publicly hosted server has nothing to rebind to.
  3. It relies on authority granted by network position. A bearer token in Authorization is never attached by the browser on the attacker's behalf, and cookies are keyed by hostname, so neither travels with the rebound request.

For the localhost rebinding attack documented by the SDKs, Host validation is sufficient to block the rebinding path. Origin carries the same signal on POST but is absent on the same-origin GET that opens the SSE stream. Both advisories fixed the bug by arming Host validation on localhost binds; the conformance scenario dns-rebinding-protection sends Host and Origin both set to evil.example.com, accepts any 4xx, and its text says "Server MUST validate the Host or Origin header"; and the TypeScript v2 docs state that on a localhost bind the Host check is what stops DNS rebinding.

Remote variants. Rebinding is not limited to localhost. It applies to any server that grants authority by network position and has a plain-HTTP listener a browser can reach:

  • An internal server on a LAN, VPN or cloud VPC (http://mcp.corp.internal), reached through an employee's browser.
  • A public server that trusts source IP (egress allowlists). The attacker points their own domain at the server's IP permanently; the victim's browser supplies the trusted address.
  • A TLS front door does not cover a plain-HTTP backend or internal hop, and a wildcard certificate plus a dangling subdomain gives the attacker a name the certificate matches.

Host validation against the names the server actually serves stops all of these; HTTPS alone does not.

Origin validation is the standard defense against cross-site request forgery on servers that accept ambient credentials. Against a remote HTTPS server that requires a bearer token on every request, a browser attacker without the token receives 401 regardless of Origin, and an attacker with the token does not need a browser.

3. The gap

Both SDKs decide policy by bind address and by whether the operator wrote an allowlist. A missing Origin always passes; a present, unlisted Origin is 403.

Server configuration curl (no Origin) Malicious page Chrome extension Firefox extension
Local, unauthenticated, SDK defaults pass 403 (correct) 403 403
Hosted + bearer auth, no allowlist pass 401 pass pass
Hosted + bearer auth, allowlist = own domain pass 403 403 403

In row 2 the bearer check already rejects the malicious page. Row 3 is what "MUST validate Origin" pushes hosted-server operators into (the server I hit is one example). For the specific rebinding threat the advisories describe, the allowlist adds little when every request to a remote HTTPS server requires a non-ambient bearer token unavailable to the attacking page, while it changes the outcome for two legitimate clients. Whether it has residual defense-in-depth value on such a server (for example if the same endpoint also accepts a cookie) is a question worth settling explicitly rather than by default.

Why operators cannot express a narrower policy today (verified against typescript-sdk v1.29.0 and @modelcontextprotocol/server 2.0.0, and python-sdk v2.2.0, by reading the code and running each SDK's own Origin/Host tests plus the same inputs through each validator):

TypeScript v1 (≤1.29) TypeScript v2 (2.0 GA) Python (2.x)
Matching exact origin string hostname only (scheme/port ignored) exact string, :* port wildcard
Scheme/hostname wildcard (e.g. chrome-extension://*) no no no
Predicate hook no no no
Skip when bearer-authenticated no no no
Order vs. auth with the SDK's own app factory¹ Host before, Origin after both before both after
Default on localhost bind Host only Host + Origin Host + Origin
Default on non-localhost bind none none none
Missing Origin pass pass pass
Origin: null pass unless allowlist set 403 403
Host failure status 403 JSON-RPC 403 JSON-RPC 421 plain text

¹ As wired by createMcpExpressApp / streamable_http_app(). Applications that compose the guards and the bearer middleware by hand choose their own order.

Two supporting data points:

  • The TypeScript SDK rejected a missing Origin when an allowlist was configured from the release that introduced the option (first shipped in 1.13.3) until 1.24.0, when PR Return tool list based on context #1205 reversed it "as they are not relevant to server DNS rebinding protection". The same reasoning applies to an authenticated request from a browser extension: a chrome-extension:// origin cannot be produced by DNS rebinding.
  • The executable conformance check requires only "Host or Origin" on localhost, so the prose MUST is already stronger than what conformance tests. The Python SDK docs likewise tell operators behind a reverse proxy that already controls Host to switch the check off, so SDK policy already varies with deployment context.

4. Proposal

Spec (transports, security best practices)

  1. State the requirement in terms of the threat: servers MUST protect against DNS rebinding; for any server that grants authority by network position (loopback, private networks, VPNs, source-IP allowlists) that means validating Host against the names it serves, on every listener; Origin checks are additionally RECOMMENDED where ambient credentials are accepted.
  2. Consider allowing servers that authenticate every MCP request, on every listener, with a verified non-ambient bearer token to opt into accepting arbitrary Origin values, while retaining Host validation for rebinding protection.

SDKs (TypeScript, Python; likely others)

  1. An authentication-aware mode: skip the allowlist (or reduce it to rejecting null/unparsable) when the request carries a verified bearer token. Under the TypeScript v2 app factories the Origin guard currently runs before bearer verification, so the factory wiring would need to change.
  2. Scheme and hostname wildcards plus a predicate, with identical semantics across SDKs.
  3. Keep Host armed by default on localhost; document allowedOrigins as the CSRF control, not the rebinding control.
  4. A machine-readable reason in the 403 body so clients can tell users that re-authorizing will not help.

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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions