Blog

DNS rebinding and MCP: why nine in ten servers skip the Origin check

· 14 min read

At the MCP Security Conference last week, one number stood out: nine in ten public MCP servers we could fully audit will answer a request sent by any website. When a server like that runs on your laptop or inside your company network, any web page you open can call its tools as if it were you. The attack is called DNS rebinding, it has already been used against MCP tooling, and the fix is a few lines in either official SDK. This post explains the attack from scratch, shows how to check your server in a minute, and gives the exact fix.

Are you affected?

Most local services are boring targets. An MCP server is not: it exists to let whoever sends it a request take actions, and it usually holds what those actions need, such as file access, a database connection, or an API token. Local servers also tend to have no authentication, because “only my machine can reach it” feels like enough. DNS rebinding is how a web page breaks that assumption.

Treat this as urgent if your MCP server speaks HTTP (stdio servers are not affected) and any of these is true:

  • it runs on a developer’s machine, even “just for testing”;
  • it runs inside a company network, a VPC, or a Kubernetes cluster that an employee’s browser can reach;
  • it ships inside a desktop application;
  • it authenticates users with a browser cookie, where a request from a foreign site is also the classic cross-site request forgery case.

A public server that needs no credentials is at less risk: a rebinding attacker gains nothing that curl could not already do. Fix it anyway. The spec requires the check of every server, it costs a few lines, and code that ships without it to production usually ships without it to the laptop, the staging cluster, and the intranet copy too, which are exactly the places where it is exploitable.

What to do today:

  1. Check your server: uvx mcpscore https://your-server/mcp and look for security_origin_validation, or use the one-line curl check below.
  2. If it fails, apply the fix for the Python SDK or the TypeScript SDK.
  3. Run the check again, and keep it in CI.

The rest of this post explains why the fix works, so you can apply it correctly to your own setup.

Origins, and why browsers keep them apart

An origin is the scheme, host, and port of a page: https://example.com and http://127.0.0.1:8765 are two different origins. Your browser uses origins to keep sites apart, a rule called the same-origin policy. Whenever a page’s script sends a request with a method other than GET, the browser adds an Origin header that names the page. The page cannot change it.

A web page can still send requests to any address your browser can reach, including 127.0.0.1 and the machines on your office network. What stops a script on attacker.example from POSTing JSON to http://127.0.0.1:8765/mcp is that the request crosses origins. The browser first asks the server for permission with a so-called CORS preflight request. An MCP server that does not answer it never receives the real request, and the page can never read an answer.

DNS rebinding removes the cross-origin part. The attacker makes the browser believe that the page and your server are the same origin, and from then on the browser applies no restrictions at all.

DNS rebinding, step by step

DNS is the phone book that turns a name like attacker.example into an IP address. Whoever owns a domain controls its entries, and each answer comes with a time-to-live: how long the browser may remember it. DNS rebinding means the attacker changes their own entry while your browser is on their page.

Say a developer runs an MCP server on their laptop on port 8765, with no authentication, because it only listens locally. The attacker owns attacker.example and its DNS server.

  1. The developer opens a page on http://rebind.attacker.example:8765, from a link, an ad, or an embedded frame. DNS resolves the name to the attacker’s web server, with a time-to-live of a few seconds, and the page loads.
  2. The page’s script waits for that DNS answer to expire. The attacker’s DNS server now answers the same name with 127.0.0.1, the developer’s own machine.
  3. The script sends fetch('/mcp', …). The browser resolves the name again, gets 127.0.0.1, and connects to the developer’s MCP server. The page and the request share an origin, so there is no preflight and the script can read the answer.
  4. The script calls tools/list, then tools/call on whatever it finds: read files, query a database, send a message, all with the developer’s local access. Nothing on screen shows it happening.

In step 3 the script needs only the JSON-RPC messages any MCP client sends:

// Runs on http://rebind.attacker.example:8765 after the name rebinds
const res = await fetch('/mcp', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Accept: 'application/json, text/event-stream',
  },
  body: JSON.stringify(initializeRequest),
})

The request that arrives carries two headers the server did not expect. That is the whole defence:

POST /mcp HTTP/1.1
Host: rebind.attacker.example:8765
Origin: http://rebind.attacker.example:8765
Content-Type: application/json

The Host header names the attacker’s domain, not 127.0.0.1 or localhost, and the Origin header names the page that sent the request. Rebinding can change which IP address a name resolves to. It cannot change the name the browser puts in those headers. A server that checks either one against a short allowlist refuses the attack.

This has already happened

The MCP Inspector’s local proxy could be driven from a web page to run commands on a developer’s machine, through DNS rebinding among other routes (CVE-2025-49596). DNS rebinding against local services is a well-documented attack class, and developer tooling that listens on a local port is exactly what it targets.

Browsers are closing part of the gap. Since version 142, Chrome asks the user before a public site may reach the local network. That helps, but only in Chromium browsers, only when the user declines the prompt, and not for a page already served from inside the network. The server check is the one you control.

What the spec requires

Every revision of the Streamable HTTP transport carries the same security warning. Servers MUST validate the Origin header on all incoming connections to prevent DNS rebinding attacks. Since revision 2025-11-25 the answer to an invalid origin is also fixed: HTTP 403 Forbidden. The same warning says a local server SHOULD bind to 127.0.0.1 rather than 0.0.0.0, and every server SHOULD authenticate its connections. The 2026-07-28 revision keeps the requirement.

The check itself is small. Non-browser MCP clients, such as an IDE, a CLI, or an agent runtime, send no Origin header. So the rule is: no Origin, serve the request; an Origin on your allowlist, serve it; any other Origin, answer 403. Your real clients never notice.

What we measured

In September we collected every remote MCP endpoint listed in six public directories, 31,503 after de-duplication, and audited each one with mcpscore 1.20.0. 11,548 answered without a login wall and served the harmless control request, so their Origin handling could be judged.

Servers that accept a foreign Origin, September 2026
ServersJudgedAccept a foreign Origin
All fully audited servers11,54810,420 (90%)
Independent hosts only5,7184,676 (82%)
2026 stateless lifecycle1,5091,008 (67%)
2025 initialize lifecycle10,0399,412 (94%)

Hosting platforms decide this for all their tenants. The 18 host families with 100 or more endpoints hold 45% of everything we found, and three of them accept a foreign origin on every one of their endpoints. Set those families aside and independent operators still accept it 82% of the time. Servers on the newest, stateless lifecycle do better, and two in three of them still accept it.

Reproduce it on your own server

Do this against servers you run or have permission to test. The example is a minimal Python server that listens on every interface and has no security settings configured yet.

# server.py · mcp 2.2.0
from mcp.server import MCPServer

mcp = MCPServer("notes")


@mcp.tool()
def search_notes(query: str) -> list[str]:
    """Search your notes and return the titles that match."""
    return [f"Note about {query}"]


if __name__ == "__main__":
    mcp.run(transport="streamable-http", host="0.0.0.0", port=8765)

1. Send a foreign origin. First the control request with no Origin, then its twin with one. A server that validates origins serves the first and refuses the second.

# Control: no Origin, as an MCP client sends
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8765/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
200

# The same request from a foreign page
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8765/mcp \
  -H 'Origin: https://evil.example' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
200 # vulnerable: a validating server answers 403

This server answers 200 and starts a session for a page on evil.example. A fixed server answers 403 Forbidden.

2. Simulate the rebound request. You do not need a DNS server to see what the server would do in step 3 above. Send the headers a rebound browser would send:

curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8765/mcp \
  -H 'Host: rebind.attacker.example:8765' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
200 # vulnerable: 421 (Python SDK) or 403 (TypeScript SDK) once fixed

The unprotected server serves it. With Host validation on, the Python SDK answers 421 Misdirected Request and the TypeScript SDK answers 403.

3. Run a real rebinding attack. To watch a browser do it end to end, NCC Group’s Singularity of Origin is an open-source DNS rebinding framework with its own DNS server and attack pages. Point it at your test machine and your server’s port, and it performs steps 1 to 3 in a real browser. It is the most convincing demo for a team that doubts the risk, and steps 1 and 2 already tell you whether you are exposed.

How mcpscore detects it

mcpscore asks the same question as step 1, with one precaution. It first sends a harmless request with no Origin. Only if the server serves that does it send the twin with Origin: https://mcpscore.invalid, a domain that cannot exist. A server that refuses everyone earns nothing either way, so an access-controlled endpoint cannot pass by accident.

uvx mcpscore 'http://127.0.0.1:8765/mcp'

# Before: the server in the example above
❌ Streamable HTTP does not reject an invalid foreign Origin with HTTP 403, risking DNS rebinding. Observed HTTP status: 200.
  Fix: Validate supplied Origin headers against the origins allowed for this endpoint. Return HTTP 403 for invalid origins; do not allow every origin to satisfy browser requests.
Audit finished. Final score: 122/151

# After: with TransportSecuritySettings
✅ Streamable HTTP rejects an invalid foreign Origin with HTTP 403.
Audit finished. Final score: 125/151

The rule is security_origin_validation, HIGH severity, worth 3 points in the Security & Auth category. It applies to every HTTP server on revision 2025-03-26 or later and never to stdio. For 2025-11-25 and later the refusal must be exactly 403; before that any 4xx passes. The JSON report records what the server answered, so the finding is reproducible and disputable:

{
  "rule_id": "security_origin_validation",
  "severity": "HIGH",
  "passed": false,
  "details": {
    "http_status": 200,
    "control_http_status": 200,
    "expected": { "http_status": 403 }
  }
}

The audit tests Origin, the header the spec names. It does not send a foreign Host, so check that one yourself with the command in step 2. Every rule is in the rules reference.

Fix it in the Python SDK

A server bound to 127.0.0.1 or localhost gets a localhost allowlist from the Python SDK automatically. For a server bound to 0.0.0.0, which means every network interface and is how containers and deployed servers listen, pass TransportSecuritySettings with the hosts and origins you serve:

from mcp.server import MCPServer
from mcp.server.transport_security import TransportSecuritySettings

...

if __name__ == "__main__":
    mcp.run(
        transport="streamable-http",
        host="0.0.0.0",
        port=8765,
        transport_security=TransportSecuritySettings(
            allowed_hosts=["notes.example.com", "127.0.0.1:*", "localhost:*"],
            allowed_origins=["https://notes.example.com"],
        ),
    )
  • allowed_hosts matches the whole Host header, including the port. name:* accepts any port. List every name the server is reached by, including the one your load balancer or health check uses. A mismatch gets 421, and an empty list rejects every request.
  • allowed_origins lists full origins with scheme, such as https://notes.example.com. A mismatch gets 403. A request without Origin passes.
  • On mcp 1.x, pass the same object to the constructor: FastMCP("notes", transport_security=…). Use 1.23.0 or later.
  • Mounting the app in your own ASGI server? Pass transport_security to mcp.streamable_http_app(). The legacy SSE transport takes the same argument.

Run it again and the audit agrees. On our example server the score went from 122/151 to 125/151: the three points of this rule.

Fix it in the TypeScript SDK

In SDK v2 the checks live in the framework adapters and helpers, not in the transport, whose old options remain only as deprecated. createMcpExpressApp from @modelcontextprotocol/express takes both lists:

import { createMcpExpressApp } from '@modelcontextprotocol/express'

const app = createMcpExpressApp({
  host: '0.0.0.0',
  allowedHosts: ['notes.example.com', 'localhost', '127.0.0.1'],
  allowedOrigins: ['notes.example.com'],
})

Both lists hold hostnames only: the check ignores scheme and port, and it rejects the opaque null origin. On a localhost bind the app applies a localhost allowlist automatically; on 0.0.0.0, pass your own lists as above. The Hono, Fastify, and Node adapters wrap the same helpers.

When you serve with createMcpHandler, the v2 entry point for the 2026-07-28 revision, add the checks in front of it. On a fetch-native runtime such as Cloudflare Workers, Deno, or Bun, put hostHeaderValidationResponse and originValidationResponse from @modelcontextprotocol/server in front of it. On plain node:http, @modelcontextprotocol/node has the same checks as hostHeaderValidation and originValidation.

On v1, @modelcontextprotocol/sdk 1.24.0 or later, the options sit on the transport:

const transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: undefined,
  enableDnsRebindingProtection: true,
  allowedHosts: ['notes.example.com'],
  allowedOrigins: ['https://notes.example.com'],
})

All three options are needed: the lists are ignored unless enableDnsRebindingProtection is true, and an empty list skips its check. The values are compared exactly, so include the port when it is not the default one. The options are deprecated in favour of middleware but still enforced, and SSEServerTransport accepts the same three.

Behind a CDN or gateway

When a gateway, CDN, or hosting platform sits in front of your server, the same rule can be one edge policy: requests with an Origin outside your list get 403, requests without one pass. That is the fix for the hosting platforms in the numbers above, and it covers every tenant at once.

One trap: CORS is not validation. Access-Control-Allow-Origin tells a browser whether a script may read an answer. It never stops the request from reaching your server. A server that answers every origin with * is still processing every request, including the rebound ones.

Recap

  • DNS rebinding lets a web page talk to a server on your machine or network as if it were the same site.
  • The Host and Origin headers still name the attacker’s site. Checking either against an allowlist stops the attack, and the spec requires the Origin check with a 403.
  • Both official SDKs have the fix built in. On a server that listens on 0.0.0.0, pass your hosts and origins.
  • Verify with one curl, then keep it verified with mcpscore in CI, where findings can also go to GitHub code scanning.

Check any server in seconds on mcpscore.dev, including the ones your agents already connect to.