Visitar URL original
Origin validation rejects browser-based MCP clients on authenticated remote servers · Issue #3370 · modelcontextprotocol/modelcontextprotocol · GitHub
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 @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 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.

More 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.

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)

  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

  1. daoluc commented on Sep 19, 2026

    @daoluc
    Author

    added more info for DNS rebinding on remote servers

  2. sattyamjjain commented on Sep 21, 2026

    @sattyamjjain

    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.

  3. daoluc commented on Sep 22, 2026

    @daoluc
    Author

    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. tvition20-bit commented on Sep 23, 2026

    @tvition20-bit

    Gh

  5. Babbar-rules commented on Sep 24, 2026

    @Babbar-rules

    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):

    1. 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?
    2. @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.

  6. JLLeitschuh commented on Sep 29, 2026

    @JLLeitschuh

    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.

  7. geosensor-tech commented on Oct 8, 2026

    @geosensor-tech

    Deployed-population measurement: 31 of 500 registry endpoints reject a browser Origin and pass without one

    This 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 endpoints value, not the apex). Up to three preflights per host, the second and third sent only when the first returns 403:

    arm Origin Access-Control-Request-Headers
    1 https://example.com content-type, authorization, mcp-session-id, mcp-protocol-version
    2 absent the same four
    3 https://example.com content-type only

    Arm 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 Origin and it asks for four headers. A server that refuses the requested headers answers 403 to arm 1 and, with no Origin at all, skips CORS processing entirely and answers a plain 405 — which is byte-for-byte the signature of an Origin rejection.

    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                      -> 405
    

    Against 2ools.app, which genuinely refuses the origin:

    2ools.app/mcp                      Origin + content-type          -> 403
                                       Origin + the four MCP headers  -> 403
                                       no Origin                      -> 204
    

    Identical two-arm signatures, opposite causes. Holding the Origin constant 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-Origin 82
    OPTIONS not implemented (405/501) 49
    403 with an Origin even for a minimal ask, reachable without one — this issue 31
    403 only when the MCP headers are requested; the origin itself is accepted 2

    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 initialize handshake and return a serverInfo (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 answer 401, 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 -> 405
    

    On "an allowlist does not scale". One host demonstrates it outright. https://2ools.app/mcp returns 403 with body {"error":"Origin not allowed"} for an unlisted origin, 204 with no origin at all, and 204 for Origin: https://claude.ai, while https://localhost:3000 is 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-id or mcp-protocol-version. A browser MCP client still cannot use them — it must send those headers — but no Origin policy 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 Origin policy is settled.

    RFC 9728 is a MUST in the MCP auth spec: the 401 carries WWW-Authenticate: … resource_metadata=…. But WWW-Authenticate is not a CORS-safelisted response header, so JavaScript cannot read it unless the server also names it in Access-Control-Expose-Headers. Of the 500 hosts, 150 gate initialize:

    hosts
    gated (initialize refused) 150
    send a WWW-Authenticate challenge 123
    challenge carries resource_metadata (RFC 9728-conformant) 116
    challenge readable by a browser client 26
    challenge present but unreadable 97
    — no Access-Control-Allow-Origin at all, so the response is blocked wholesale 68
    — CORS works, but WWW-Authenticate is absent from Expose-Headers 29

    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 401 whose 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-Id and 10 of those expose it. I would not lean on that ratio — a stateless server cannot fail the test, and a server that gates initialize never 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 Origin policy 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.app above is the only case where that was established by hand.
    • Among the 31, 6 answer 401 to initialize, 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.

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