Repository navigation
Description
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 @kurtisvg and @pcarleton, 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
Originand 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
Originthrough. - That creates an interoperability asymmetry for browser-hosted MCP clients: a native request with no
Originsucceeds, while an otherwise identical authenticated browser request is rejected because it truthfully supplies itsOrigin. - The DNS-rebinding advisories that motivated these protections specifically concern local HTTP servers without authentication, and current SDK documentation identifies
Hostvalidation as the key protection against that rebinding path. - The open design question is whether
Originpolicy 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:
- It runs over plain
http://. Over TLS the certificate the server presents does not matchevil.example. - It targets a listener the attacker cannot reach directly (loopback, private network). A publicly hosted server has nothing to rebind to.
- It relies on authority granted by network position. A bearer token in
Authorizationis 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.
More supporting data points:
- The TypeScript SDK rejected a missing
Originwhen 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: achrome-extension://origin cannot be produced by DNS rebinding. - The executable conformance check requires only "
HostorOrigin" 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 controlsHostto switch the check off, so SDK policy already varies with deployment context.
Interesting find: the Go SDK already made this split.
1.4.0: Host (loopback) protection on by default, no Origin check.
1.4.1 to 1.5.0: Origin protection also on by default.
1.6.0 onward: Origin protection off by default (nil = none), Host protection stays on. The re-enable knob was removed in 1.8.0.
So the Go maintainers reversed "Origin protection by default" within two minor releases, kept Host checking on, and their rough_edges.md now says CrossOriginProtection "should not have been part of the SDK API" because cross-origin protection "is a general HTTP concern, not specific to MCP" that belongs in ordinary HTTP middleware.
4. Proposal
Spec (transports, security best practices)
- 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
Hostagainst the names it serves, on every listener;Originchecks are additionally RECOMMENDED where ambient credentials are accepted. - Consider allowing servers that authenticate every MCP request, on every listener, with a verified non-ambient bearer token to opt into accepting arbitrary
Originvalues, while retainingHostvalidation for rebinding protection.
SDKs (TypeScript, Python; likely others)
- 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 theOriginguard currently runs before bearer verification, so the factory wiring would need to change. - Scheme and hostname wildcards plus a predicate, with identical semantics across SDKs.
- Keep
Hostarmed by default on localhost; documentallowedOriginsas the CSRF control, not the rebinding control. - A machine-readable reason in the 403 body so clients can tell users that re-authorizing will not help.
Activity
daoluc commentedon Sep 19, 2026
added more info for DNS rebinding on remote servers
sattyamjjain commentedon Sep 21, 2026
daoluc commentedon Sep 22, 2026
Interesting find: the Go SDK already made this split.
1.4.0: Host (loopback) protection on by default, no Origin check.
1.4.1 to 1.5.0: Origin protection also on by default.
1.6.0 onward: Origin protection off by default (nil = none), Host protection stays on. The re-enable knob was removed in 1.8.0.
So the Go maintainers reversed "Origin protection by default" within two minor releases, kept Host checking on, and their rough_edges.md now says CrossOriginProtection "should not have been part of the SDK API" because cross-origin protection "is a general HTTP concern, not specific to MCP" that belongs in ordinary HTTP middleware.
tvition20-bit commentedon Sep 23, 2026
Gh
Babbar-rules commentedon Sep 24, 2026
I'd like to help move this forward on the spec side. I've drafted a change to the Streamable HTTP "Security & Endpoint" section (draft spec) that follows the proposal above:
This only relaxes a requirement, so servers that validate Origin today stay compliant. I've left the machine-readable 403 reason and the SDK changes out of scope, since those are a protocol change and per-SDK work.
Before opening anything, two questions for maintainers and the transports WG (cc @kurtisvg @pcarleton):
Disclosure: I drafted this wording with the help of an AI assistant and reviewed it myself.
JLLeitschuh commentedon Sep 29, 2026
Worth pointing out that the reason that I advocated for a fail-closed approach to fixing these vulnerabilities writ-large is because of the long, long history of browser-exploitable vulnerabilities caused by engineers exposing their locally running servers to the browser:
WICG/local-network-access#21
Current status, Chrome guards against this with a prompt, whereby the site asks "This site would like to talk to local devices on your network" and the end-user must approve that action. AFAIK, all other browsers, except Brave, lack that permission grant.
geosensor-tech commentedon Oct 8, 2026
Deployed-population measurement: 31 of 500 registry endpoints reject a browser
Originand pass without oneThis thread has been settled on SDK source, release history and threat models, which is the right way to establish what the code does. Nobody has reported what deployed servers do, so here is a census aimed at the open question. Measured 2026-10-08.
Method, and one confound worth knowing about if you measure this yourself
500 entries sampled from the MCP registry with a recorded seed; each host's advertised endpoint probed (the
endpointsvalue, not the apex). Up to three preflights per host, the second and third sent only when the first returns403:OriginAccess-Control-Request-Headershttps://example.comcontent-type, authorization, mcp-session-id, mcp-protocol-versionhttps://example.comcontent-typeonlyArm 3 exists because two arms cannot attribute the refusal, and I got this wrong first time. Arm 1 differs from a bare preflight in two ways at once — it carries an
Originand it asks for four headers. A server that refuses the requested headers answers403to arm 1 and, with noOriginat all, skips CORS processing entirely and answers a plain405— which is byte-for-byte the signature of anOriginrejection.That is not hypothetical. It is what Google's own MCP endpoints do:
Against
2ools.app, which genuinely refuses the origin:Identical two-arm signatures, opposite causes. Holding the
Originconstant and dropping the ask separates them. My first pass reported 33 Origin rejections; after adding arm 3 it is 31 Origin-driven and 2 header-driven. 1,040 requests for the census, plus the re-probe.The asymmetry, measured
On an evaluated denominator — excluding hosts that never answered, refusals unrelated to
Origin, and endpoints that simply are not there (404/410, a different fault) — 164 / 362 = 45.3% of registry-listed endpoints are unreachable from a browser origin:Access-Control-Allow-OriginOPTIONSnot implemented (405/501)403with anOrigineven for a minimal ask, reachable without one — this issue403only when the MCP headers are requested; the origin itself is acceptedAll 33 candidates were verified to be real MCP servers rather than registry junk, since a status code is not sufficient evidence: 27 complete a full
initializehandshake and return aserverInfo(2ools 0.5.0,agent-base 1.29.0,companygraph-mcp-server 0.58.0,unquant 1.26.0,internet-janitor 0.2.6, …); the other 6 answer401, so they are gated but unmistakably MCP. No false positives.Reproducible examples of the 31:
On "an allowlist does not scale". One host demonstrates it outright.
https://2ools.app/mcpreturns403with body{"error":"Origin not allowed"}for an unlisted origin,204with no origin at all, and204forOrigin: https://claude.ai, whilehttps://localhost:3000is refused. The allowlist exists, it admits a major client, and it refuses everyone else — which is the position the opening comment describes from the client side. Being straight about method: I established that by sending an origin that is not mine, as a one-off manual check. The census itself never does; it sends one foreign origin and compares against no origin, which is enough for the asymmetry.And the two header-driven cases are worth their own line, because they are a major deployment and they fail for a reason this thread has not covered: Google's MCP endpoints accept a browser origin but refuse a preflight that asks for
authorization,mcp-session-idormcp-protocol-version. A browser MCP client still cannot use them — it must send those headers — but noOriginpolicy change would fix it.A second way browser clients are second-class on authenticated servers
Same traffic, no extra requests. This issue is specifically about authenticated remote servers, and there is an independent mechanism that makes the auth path unusable from a browser however
Originpolicy is settled.RFC 9728 is a MUST in the MCP auth spec: the
401carriesWWW-Authenticate: … resource_metadata=…. ButWWW-Authenticateis not a CORS-safelisted response header, so JavaScript cannot read it unless the server also names it inAccess-Control-Expose-Headers. Of the 500 hosts, 150 gateinitialize:initializerefused)WWW-Authenticatechallengeresource_metadata(RFC 9728-conformant)Access-Control-Allow-Originat all, so the response is blocked wholesaleWWW-Authenticateis absent fromExpose-Headers90 of the 116 RFC 9728-conformant challenges (78%) cannot be read by a browser client. The mandated half is satisfied on the wire and the unmandated complement nullifies it inside a single transaction: the client receives a
401whose challenge it cannot parse, so it can never discover where to authenticate. Same practical outcome as this issue, different mechanism.This is known as a library bug — cyanheads/mcp-ts-core#687 reports exactly it, and FastMCP's deployment docs tell operators to set
expose_headers— but I can find no report of the deployment rate.For completeness: 35 hosts issue an
Mcp-Session-Idand 10 of those expose it. I would not lean on that ratio — a stateless server cannot fail the test, and a server that gatesinitializenever reveals whether it issues one — so the denominator is not observable from outside. Reported, not relied upon.Limits, stated plainly
Originpolicy from network filtering, and I cannot do that from here.403; we cannot distinguish an SDK default from a deliberate operator allowlist.2ools.appabove is the only case where that was established by hand.401toinitialize, so their MCP identity rests on shape rather than a completed handshake.Happy to re-run any host, hand over all 31 with raw headers, or re-probe from another vantage if someone can offer one. If the cut that matters for the spec decision is different — local versus remote, authenticated versus open, SDK-identifiable versus not — say which and I will compute it from the rows rather than guess.
Disclosure: the probe, the census and this write-up were prepared with Claude Code. The confound in the method was caught by hand-checking a named example before posting, which is the only reason the 33/31 correction happened before publication rather than after.
daoluc commentedon Oct 20, 2026
@JLLeitschuh can you pls elaborate your specific concern?
JLLeitschuh commentedon Oct 21, 2026
I'm hopping on a call with some people from the anthropic team later this week to discuss this more. I'm not fully sure I understand what the problem is and what is trying to be solved here.
The entire message from @geosensor-tech makes absolutely no sense to me. I'm getting AI slop vibes
sattyamjjain commentedon Oct 31, 2026
daoluc commentedon Nov 6, 2026
I discussed with @JLLeitschuh on a call. Host validation could be sufficient to protect against dns binding attack. We would like to discuss this further in the working group meeting.
@pcarleton @felixweinberger can we discuss this on the next meeting