Repository navigation
Origin validation rejects browser-based MCP clients on authenticated remote servers #3370
Description
Activity
added more info for DNS rebinding on remote servers
Good write-up. The Host-vs-Origin split is the part I think the spec should take.
One independent data point for the WG. I maintain agent-audit-kit, a static scanner for MCP pipelines, and it landed on your proposal 3 from the deployment side rather than the spec side.
The rebinding rule only accepts a Host allow-list as the control. Origin is not in the guard at all; the check passes on TrustedHostMiddleware, allowed_hosts=, allowedHosts, validate_host, and nothing else. The string "origin" does not appear anywhere in that scanner. So every server we mark as fixed is fixed on Host, and an Origin allowlist alone never clears the finding.
The suppression clause on the sidecar rule, written 2026-08-16, is close to your section 2:
Suppressed when the app enforces request-borne credentials (bearer token, API-key header), which a rebinding attacker cannot supply; not suppressed by cookie or session auth, which the browser attaches on the attacker's behalf.
One place we differ, and I think you are right and my rule text is loose. You say cookies are keyed by hostname, so they do not travel with the rebound request. That is correct for rebinding specifically, since the page is still on evil.example and the browser sends evil.example cookies. My wording is carrying over the classic CSRF case, where the victim is on the real origin. Worth the spec being explicit about which of the two it is defending, because the answer changes whether Origin buys anything.
Two other things from running this against real servers:
The pin check and the pattern check have to be separate. Version-pinning the SDK above the fix does nothing for a downstream server that embeds StreamableHTTP behind its own app factory and never wires the guard. We cover CVE-2025-66414 / 66416 with both, and in practice the pattern one fires far more often.
On your point 4, the machine-readable reason in the 403 body, I would push that harder than the issue does. From outside a deployment, a scanner cannot tell an Origin rejection from a Host rejection from a bearer rejection, so tooling has to guess which control fired and mostly guesses wrong. A reason code fixes that for clients and for anyone auditing a server they did not write.
Rules and the CVE-to-rule ledger: https://github.com/sattyamjjain/agent-audit-kit
Happy to write the Host-vs-Origin wording up as a security-best-practices PR if the WG wants it.
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.
Gh
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:
- Rebinding is handled by Host. Servers MUST protect against DNS rebinding. Servers that grant access by network position (localhost, private networks, source-IP trust) MUST validate Host against the names they serve, on every listener.
- CSRF is handled by Origin. Servers MUST validate Origin if they accept ambient credentials (cookies, or anything else the browser attaches automatically), and SHOULD validate it otherwise.
- Bearer-only exemption. Servers that authenticate every request, on every listener, with a credential the browser does not attach automatically (e.g. a bearer token in Authorization) MAY accept any Origin. They still have to validate Host where the rebinding rule applies.
- 403 unchanged. If a server validates Origin and it is present and invalid, it still MUST return 403.
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):
- Is this the direction you want, and is a regular PR enough, or should it go through a SEP since it changes a normative MUST?
- @sattyamjjain offered to write up the Host versus Origin wording for security best practices. Happy to coordinate so we don't end up with two competing PRs. I can take the transport spec section if you take the best-practices page.
Disclosure: I drafted this wording with the help of an AI assistant and reviewed it myself.
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:
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.
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:arm OriginAccess-Control-Request-Headers1 https://example.comcontent-type, authorization, mcp-session-id, mcp-protocol-version2 absent the same four 3 https://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:
bigtableadmin.googleapis.com/mcp Origin + content-type -> 200 Origin + the four MCP headers -> 403 no Origin -> 405Against
2ools.app, which genuinely refuses the origin:2ools.app/mcp Origin + content-type -> 403 Origin + the four MCP headers -> 403 no Origin -> 204Identical 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:cause hosts answers the preflight but sends no Access-Control-Allow-Origin82 OPTIONSnot implemented (405/501)49 403with anOrigineven for a minimal ask, reachable without one — this issue31 403only when the MCP headers are requested; the origin itself is accepted2 All 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:
https://2ools.app/mcp Origin -> 403 no Origin -> 204 https://mcp.companygraph.io/mcp Origin -> 403 no Origin -> 405 https://unquant.ai/mcp Origin -> 403 no Origin -> 405 https://internet-janitor.capneura.workers.dev/mcp Origin -> 403 no Origin -> 200 https://base.autosophia.io/mcp Origin -> 403 no Origin -> 405On "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:hosts gated ( initializerefused)150 send a WWW-Authenticatechallenge123 challenge carries resource_metadata(RFC 9728-conformant)116 challenge readable by a browser client 26 challenge present but unreadable 97 — no Access-Control-Allow-Originat all, so the response is blocked wholesale68 — CORS works, but WWW-Authenticateis absent fromExpose-Headers29 90 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
- One vantage: a residential line in Portugal. A host that geofences or blocks that network reads as refused here and may be reachable elsewhere. This is the limit I would most like someone to break — the same probe from a datacentre IP would separate
Originpolicy from network filtering, and I cannot do that from here. - 500 of the registry, not all of it, with a recorded seed.
- One foreign origin tested. A host whose allowlist happens to include it would read as reachable, which biases the 31 downward.
- Status, not intent. We observe a
403; we cannot distinguish an SDK default from a deliberate operator allowlist.2ools.appabove is the only case where that was established by hand. - Among the 31, 6 answer
401toinitialize, so their MCP identity rests on shape rather than a completed handshake. - One day. A re-probe roughly 40 minutes after the census reproduced all 33 hosts as unreachable, and that second pass is where the 31/2 split was established. I have not watched these hosts over time, so I cannot say whether any of it is stable.
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.
- One vantage: a residential line in Portugal. A host that geofences or blocks that network reads as refused here and may be reachable elsewhere. This is the limit I would most like someone to break — the same probe from a datacentre IP would separate
Originvalidation rejects browser-based MCP clients on authenticated remote servers: SDK comparison and threat modelFollow-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
Originand return 403 when it is invalid, but it does not define a universal validation policy.Originthrough.Originsucceeds, while an otherwise identical authenticated browser request is rejected because it truthfully supplies itsOrigin.Hostvalidation as the key protection against that rebinding path.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.Originheader (curl, native client)Origin: chrome-extension://<id>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.exampleloads, then its DNS answer flips to127.0.0.1. The page fetcheshttp://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 withHost: evil.example:3000, and on POST alsoOrigin: http://evil.example:3000. If the server has no authentication, the attacker drives its tools.Three properties of that attack matter for the discussion:
http://. Over TLS the certificate the server presents does not matchevil.example.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,
Hostvalidation is sufficient to block the rebinding path.Origincarries the same signal on POST but is absent on the same-origin GET that opens the SSE stream. Both advisories fixed the bug by armingHostvalidation on localhost binds; the conformance scenariodns-rebinding-protectionsendsHostandOriginboth set toevil.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 theHostcheck 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:
http://mcp.corp.internal), reached through an employee's browser.Hostvalidation against the names the server actually serves stops all of these; HTTPS alone does not.Originvalidation 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 ofOrigin, 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
Originalways passes; a present, unlistedOriginis 403.Origin)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-sdkv1.29.0 and@modelcontextprotocol/server2.0.0, andpython-sdkv2.2.0, by reading the code and running each SDK's own Origin/Host tests plus the same inputs through each validator)::*port wildcardchrome-extension://*)Hostbefore,OriginafterHostonlyHost+OriginHost+OriginOriginOrigin: nullHostfailure status¹ 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:
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.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)
Hostagainst the names it serves, on every listener;Originchecks are additionally RECOMMENDED where ambient credentials are accepted.Originvalues, while retainingHostvalidation for rebinding protection.SDKs (TypeScript, Python; likely others)
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.Hostarmed by default on localhost; documentallowedOriginsas the CSRF control, not the rebinding control.