Skip to content
Chapter 7 of 8 · TransportsLoopback binding and Origin validation

14 min read

Loopback binding and Origin validation

On this page

A server bound to a local port has no perimeter around it. The two things that feel like protection — the address it is bound to, and the browser's same-origin policy — protect against narrower threats than they appear to, and a server exposing tools with side effects is a worse thing to leave open than the local dashboards and dev servers people have been leaving open for years.

This is the bill for the single-artefact shortcut from the first chapter of this topic: running the streamable HTTP build on each laptop rather than maintaining a separate stdio build. It is a real saving in build maintenance and it converts an arrangement with no listening socket at all into hundreds of listening sockets on machines that also browse the web. What follows is what has to be configured to get back to something defensible, and what each control does and does not cover.

Every interface the machine has

0.0.0.0 means every interface the machine has, which on a laptop means the office wifi, the hotel wifi, the phone hotspot, and the VPN. Nobody types that flag out of carelessness; it is copied from a context where it was correct, and the contexts are common enough to list.

Why 0.0.0.0 was typedWhat it fixes thereThe loopback-preserving version
Server runs in a container127.0.0.1 inside the namespace is unreachable from the hostbind 0.0.0.0 inside, publish as -p 127.0.0.1:8931:8931
Server runs in WSL2 or a VMthe guest's loopback is not the host'sbind inside the guest, forward the port to the host's loopback
Developing against a remote boxthe port is on the wrong machinessh -L 8931:127.0.0.1:8931 devbox
A phone or a second machine must reach itgenuinely needs the networkbind the network, and require a token

In the first three rows the isolation is supplied by something other than the bind address — a network namespace, a hypervisor, an SSH tunnel — and the flag is safe because of that companion. Carrying the flag into a README for processes running directly on laptops takes the syntax and leaves the isolation behind.

The exposure that survives a firewall

Fixing the bind address to 127.0.0.1 closes the network hole and leaves the more surprising one open, because the developer's own browser is a program on the developer's own machine and it will make requests on behalf of whatever page is open in it. A page at https://recipes.example can contain JavaScript that posts to http://127.0.0.1:8931/mcp, and the request originates from inside the loopback interface where every firewall rule and VPN policy has already been satisfied.

Every step in that is checkable, and not one of them requires the machine to have been compromised first. The browser is an ordinary program running as the developer, so it can open a TCP connection to any port on the machine it is running on, exactly as curl could. It executes JavaScript supplied by whatever page is loaded, because that is what a browser is. A fetch in that JavaScript is a request the browser makes — a page has no sockets of its own — so the connection is opened by a trusted local program on the attacker's instructions. And the packet carrying it is addressed to the loopback interface, so it is never seen by a firewall, a VPN client or an endpoint agent, all of which sit on the path out of the machine and none of which are on this path. The attacker never needed code execution on the laptop. They needed the developer to open a tab.

Two mechanisms in the browser stand between that page and the server, and neither of them is what people assume.

The same-origin policy — an origin being the scheme, host and port of a page taken together — governs whether the page may read the response. It does not prevent the request from being sent, and for MCP that distinction matters more than usual, because the tools have side effects. A blind POST that the attacker cannot read is still a write_file, a run_query that drops a table, a git_commit, or a send_email. The attacker who cannot see the answer may still have caused the thing.

The belief this displaces is worth stating in the form people actually hold it: the same-origin policy stops one site from touching another, so a page cannot talk to a local server. It is tempting because it is an accurate summary of what the policy is for — it exists so that a page cannot read your bank balance out of another tab. What it enforces, though, is a rule about reading responses, and the rule was drawn there rather than around sending because the web already permitted cross-origin sends long before the policy was written: images, scripts and HTML forms have always been able to point at other sites, and forbidding that retroactively would have broken most of what existed. For a server whose job is to hand back documents that distinction is nearly harmless, since a request whose answer nobody can read causes nothing. For a server whose tools have side effects it is the entire exposure, because the answer was never the point.

What actually gates the request is the preflight. A cross-origin POST with Content-Type: application/json is not a simple request, so the browser first sends an OPTIONS and refuses to send the POST unless the server answers with permissive CORS headers — cross-origin resource sharing being the mechanism by which a server names the other origins it is willing to be called from. This is a real protection, and it is routinely dismantled by the person setting the server up, because the first thing anyone does when a browser-based client reports a CORS error is to make the error go away.

python
# the line that turns a preflight-protected server into an open one
app.add_middleware(CORSMiddleware, allow_origins=["*"],
                   allow_methods=["*"], allow_headers=["*"])

With that in place, recipes.example gets its preflight approved, its POST delivered, and its response readable. The same outcome arrives by a different road on servers lenient about content type, since a POST with Content-Type: text/plain counts as a simple request — one of the small set of shapes an ordinary HTML form could have produced, which the browser sends without asking first — and skips the preflight entirely, and a server that parses the body regardless of the declared type has accepted a cross-site request from every page on the internet.

The shape of that rule follows from the same history, which is why the text/plain route is not a browser defect waiting to be fixed. A browser asks permission before sending a cross-origin request only when the request is one an ordinary HTML form could not have produced, because the ones a form could have produced were already crossing origins freely before anybody wrote the rule, and blocking them would have broken the web rather than secured it. A form can post text/plain. A form cannot post application/json and cannot set a custom header. So the declared content type is what decides whether the preflight happens at all, and a server that sniffs the body and parses it as JSON whatever the header says has removed the gate while leaving it in its own documentation.

Rebinding, and why the defence is a header rather than an address

Suppose the server refuses requests whose source is not loopback. That check is satisfied by anything the browser sends, since the browser is on loopback, and there is an older technique, DNS rebinding, that defeats address-based reasoning generally: the attacker's domain resolves to the attacker's own server for long enough to load a page, then re-resolves to 127.0.0.1 while that page is still running.

Sequence: Developer's browser → attacker-dns (A? evil.example (TTL 1s)); attacker-dns → Developer's browser (203.0.113.9); Developer's browser → attacker web server (GET https://evil.example/); attacker web server → Developer's browser (page with JS that polls); Developer's browser → attacker-dns (A? evil.example); attacker-dns → Developer's browser (127.0.0.1); Developer's browser → MCP server on 127.0.0.1:8931 (POST http://evil.example:8931/mcp Origin: http://evil.example); MCP server on 127.0.0.1:8931 → Developer's browser (tool result, readable by the page).

After the rebind the browser considers evil.example and the local server to be the same origin, so the same-origin policy stops helping and the response becomes readable without any CORS cooperation at all. DNS rebinding is not theoretical against local services: Transmission's RPC interface fell to it in 2018, and the pattern has been used against local Ethereum nodes, developer tooling and home routers repeatedly since. The related case that needs no rebinding at all is Zoom's 2019 macOS client, which ran a local web server on port 19421 so that any web page could put the user into a call with the camera on; Apple ended up pushing a silent removal to affected machines.

What survives rebinding is checking the headers rather than the addresses. The Origin header still says http://evil.example after the rebind, because the page's origin has not changed, and the Host header says the same. A server with an allowlist of origins rejects the request whatever the DNS is doing. This is why the MCP specification's local-transport guidance is stated as two rules together: validate the Origin header on all incoming connections, and bind to 127.0.0.1 rather than 0.0.0.0.

The reason one of those checks survives the rebind and the other does not is a difference in when each value is fixed. An origin is a property of the document: it is decided once, when the page is loaded, from the URL that was typed or clicked, and nothing the network does afterwards changes it. An address is resolved per connection, so it is decided freshly every time the page fetches anything — which is exactly the moment the attacker controls, since they run the name server. A check bound to something fixed at load time cannot be moved by anything that happens later. A check bound to something resolved at fetch time can be moved by whoever answers the next query. That generalises past this attack: bind the check to the thing the attacker had to commit to, not to the thing they can re-answer at will.

python
ALLOWED_ORIGINS = {"vscode-webview://repo-index", "http://127.0.0.1:8931"}
ALLOWED_HOSTS = {"127.0.0.1:8931", "localhost:8931"}

class OriginGuard(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        origin = request.headers.get("origin")
        if origin is not None and origin not in ALLOWED_ORIGINS:
            log.warning("rejected origin=%r host=%r ua=%r", origin,
                        request.headers.get("host"), request.headers.get("user-agent"))
            return JSONResponse({"error": "forbidden origin"}, status_code=403)
        if request.headers.get("host") not in ALLOWED_HOSTS:
            return JSONResponse({"error": "forbidden host"}, status_code=403)
        return await call_next(request)

Rejecting a missing Origin is a decision worth making deliberately, since non-browser clients do not send one and legitimate MCP clients are mostly not browsers. The common compromise is to allow a missing origin and require a bearer token, which returns the burden to authentication where it belongs.

Coverage, and the column nobody thinks about

The controls are cheap individually and it is easy to ship one and believe the problem is solved, so it is worth being explicit about coverage.

ControlRemote scan of the portCross-site POST from a pageDNS rebindingAnother process on the same machine
Bind to 127.0.0.1stopsno effectno effectno effect
Validate Origin and Hostno effectstopsstopsno effect
Strict CORS (no wildcard)no effectstops reading, not sendingno effect after rebindno effect
Bearer token issued at launchstopsstopsstopsstops if the token is unreadable
Random port written to a 0600 fileslowsslowsslowsstops if the file is unreadable

Read that table down its columns rather than across its rows and it says something no individual control does. The last column — another process running as the same user — is blank for the first three rows entirely, and the blankness is structural rather than an oversight. All three of those controls work by discriminating on where a request came from: which interface it arrived on, which document sent it, which origin the browser will admit to. A process on the same machine, running as the same user, is coming from precisely where the legitimate client comes from, so there is no property of the connection left to discriminate on. Only a secret that the legitimate client holds and the other process does not can separate them, which is exactly why the two rows that fill that column are the two involving a secret.

The bottom two rows are the ones that cover the last column. Loopback binding is not authentication: every other process running as that user — a build script, a postinstall hook from a dependency updated yesterday, a browser extension holding the native messaging permission that lets it launch local programs, another developer SSH'd into a shared box — can connect to 127.0.0.1:8931 and call any tool. A token that the launching client is handed and that lives in a file only the user can read closes that, and it is about fifteen lines of work.

bash
$ repo-index-mcp serve --host 127.0.0.1 --port 0 --token-file ~/.repo-index/token
INFO  listening on 127.0.0.1:53114
INFO  token written to /Users/dana/.repo-index/token (mode 0600)
INFO  origin allowlist: vscode-webview://repo-index

The client side of that is a single line in the launch configuration, since the same process that starts the server can read the token file it wrote and put the value in an Authorization header. Where the token comes from matters less than that it exists and that it is not in the command line, where every other process on the machine can read it out of ps. An environment variable is better than an argument; a file with mode 0600 is better than an environment variable, because a child process inherits the environment and a file has to be opened deliberately. Binding to port 0 and letting the kernel choose is a small extra: an attacker who knows the port exists but not which one has to scan 65,535 ports from inside the browser, which is slow and noisy. It buys time rather than safety, and it is worth having only on top of the token.

Pricing that honestly matters, because a random port is easy to over-credit. From inside a browser, finding the port means issuing one cross-origin request per candidate and reading how each attempt fails, and against a fixed port that is a single request. Against a kernel-chosen one it is a sweep across tens of thousands of candidates, which takes real time, generates a great deal of local traffic, and is the sort of thing endpoint tooling can be made to notice. None of that is a defence, because the sweep does finish and the attacker only has to succeed once per visit. What it buys is that the cheap version of the attack stops working and the version that still works is loud — which is worth having on top of a token and worth nothing in place of one.

When the client genuinely is a browser

Some hosts are web applications, and some editors run their UI in a webview, so there are legitimate cases where a browser is the client and CORS has to be configured rather than disabled. The difference is entirely in the specificity. An explicit list of origins, with Access-Control-Allow-Credentials left off unless something actually needs cookies, and Mcp-Session-Id named in both Access-Control-Allow-Headers and Access-Control-Expose-Headers so the client can read the session id back out of the response, produces a server that the intended page can use and that recipes.example cannot. The wildcard version is what gets written because it makes the console error disappear in one line, and the error it silenced was the browser doing its job.

There is one more address worth naming, as an argument against relying on any single layer of this. In August 2024 researchers documented that requests to http://0.0.0.0:PORT were routed to the local machine by Chrome, Safari and Firefox while bypassing the protections those browsers applied to 127.0.0.1 and localhost, so a page could reach a local service that had been assumed unreachable. Browsers have since restricted it. The lesson is not about that particular address: browser behaviour toward local services is a moving target maintained by somebody else, and a control that lives in your own process is the one you can reason about.

Set against all of this, the arrangement it was meant to replace has no listening socket at all. A stdio server is reachable by exactly one thing, the process that spawned it, and there is no address for a web page to post to, no port for a scanner to find, no origin to validate and no token to issue. Every control in the coverage table exists to reconstruct, imperfectly and with configuration that can be got wrong, a property that the parent-child relationship provides structurally.

That is the concrete price of the single-artefact shortcut, and it is not an argument that streamable HTTP is unsafe. A shared deployment behind an ingress, with single sign-on (SSO) in front and tokens validated per request, is a well-understood thing to operate. Running that same server on 200 laptops means running 200 unauthenticated services on machines that also browse the web, and the savings in build maintenance are small next to that.

None of it says anything about what the server may do once a legitimate caller reaches it. The stdio build has no listening socket and it also has dana's credentials, dana's filesystem and dana's network position, which is the part a security review is most likely to wave through.

Transports — read the current version at https://daviddominguez.dev/learn/en/transports/loopback-binding-and-origin-validation

Back to top