Agent and AI engineering · Practical technical guide · 20 October 2026
Why a local agent dev server or MCP endpoint on 127.0.0.1 is reachable from any web page through DNS rebinding, why checking the Origin header alone misses same-origin GET requests, how Host and Origin allowlists close the gap, and what the ADK Go 2.6.0 launcher change applies to every route.
Localhost is not a security boundary for HTTP
Agent development has filled laptops with HTTP servers on loopback ports: an agent framework's web UI and API, a local MCP server speaking Streamable HTTP, a model server such as vLLM or a local inference daemon, an A2A endpoint serving an agent card. Each one assumes that binding to 127.0.0.1 keeps strangers out. Against remote hosts it does. Against the browser on the same machine it does not, because the browser will send requests to loopback on behalf of any web page the developer happens to have open.
Two classes of request matter. A cross-site request from https://evil.example to http://127.0.0.1:8000 is sent by the browser, but the browser does not let the page read the response unless the server opts in through CORS. Simple POST requests still reach the server and can trigger actions. DNS rebinding is worse: it makes the attacker's page same-origin with the local server, so the browser lets the page read every response.
How rebinding works, step by step
- The developer visits
http://rebind.attacker.example:8000. The attacker’s DNS server answers with the attacker’s IP address and a TTL of a few seconds, and the page loads its script. - The script waits until the browser’s DNS cache entry expires, then calls
fetch("/api/sessions"), a relative URL on its own origin. - The browser resolves the name again. This time the attacker’s DNS server answers 127.0.0.1.
- The request goes to the local agent server on port 8000. For the browser the origin has not changed, so the request is same-origin and the script can read the response.
- The script lists sessions, reads conversation state or calls a tool endpoint, and posts the results back to a second attacker host.
Nothing in this sequence involves CORS, because there is no cross-origin request. The server receives one clue that something is wrong: the Host header says rebind.attacker.example:8000 instead of a loopback name. Whether it also receives an Origin header depends on the method. The Fetch Standard appends Origin when the response tainting is cors, which is not the case for a same-origin request, or when the method is neither GET nor HEAD. A rebinding GET therefore usually arrives without any Origin at all, while a rebinding POST carries Origin: http://rebind.attacker.example:8000.
Host and Origin do different jobs
| Request | Host header | Origin header | Stopped by a Host allowlist | Stopped by an Origin allowlist |
|---|---|---|---|---|
| Developer UI, same origin GET | localhost:8000 | absent | No, allowed | No, allowed |
| Rebinding GET | rebind.attacker.example:8000 | absent | Yes | No: nothing to check |
| Rebinding POST | rebind.attacker.example:8000 | http://rebind.attacker.example:8000 | Yes | Yes |
| Cross-site simple POST | 127.0.0.1:8000 | https://evil.example | No: Host is loopback | Yes |
| Local tool such as curl, no Origin | 127.0.0.1:8000 | absent | No, allowed | No, allowed |
Neither check alone covers the table. An Origin check misses the rebinding GET, which is the request that reads data. A Host check misses the ordinary cross-site POST, which can trigger effects. The MCP specification's Streamable HTTP transport requires servers to validate the Origin header on all incoming connections and answer 403 when it is present and invalid, recommends binding to 127.0.0.1 rather than every interface when running locally, and recommends authentication. A local server should do all of that and also check Host.
What ADK Go 2.6.0 changed
The ADK Go release v2.6.0 of 13 October 2026 includes PR #1641, merged on 2 October, titled "check Host and Origin on every web launcher route". The PR description explains the gap it closes: earlier Host and Origin checks applied only to the REST API routes, while the launcher's other routes, namely A2A, the Pub/Sub and Eventarc triggers, Agent Engine, the web UI and /health, were served without them. The change applies one guard as middleware on the launcher's root router, built after every sub-launcher is set up, so a sub-launcher added later is covered automatically.
The behaviour changes listed in the PR are worth reading before upgrading. On a loopback host, which is the default, every route now checks Host, and a request whose Host is neither loopback nor the host of an allowed origin gets 403; a reverse proxy or tunnel on the same machine therefore needs its origin added with the new repeatable -allow_origins flag. A browser request from an unlisted origin gets 403 on the A2A, trigger, Agent Engine and agent card routes, as it already did on the API. Passing -webui_address "*" turns the checks off on every route. Servers bound to 0.0.0.0, as the Cloud Run and Agent Engine deploy images are, have no Host check, and server-to-server callers that send no Origin are unaffected there.
The same week brought a related default change elsewhere: vLLM 0.32.0 stopped allowing every CORS origin by default (PR #61009). That change controls which pages may read responses cross-origin. Its PR notes that CORS does not replace authentication and does not stop every simple cross-origin request from being sent, and it does nothing about rebinding, which is same-origin.
A guard for any local server
The guard is small. Compare the Host header against an exact set of loopback names with the server's port, compare Origin, when present, against an exact set of allowed origins, and reject everything else with 403 before routing. Exact comparison matters: a substring test for "localhost" accepts localhost.attacker.example.
Illustrative python, checked for syntax; adapt names and storage to your system.
LOOPBACK = {"localhost", "127.0.0.1", "[::1]"}
def check_request(headers, port, allowed_origins):
"""Return None to allow, or a reason string for a 403. Run before routing, on every route."""
host = headers.get("host", "").lower()
if host not in {f"{name}:{port}" for name in LOOPBACK} | {o.split("://", 1)[1] for o in allowed_origins}:
return "host not allowed: " + host
origin = headers.get("origin")
if origin is not None:
if origin == "null":
return "opaque origin rejected"
if origin.lower() not in {f"http://{name}:{port}" for name in LOOPBACK} | set(allowed_origins):
return "origin not allowed: " + origin
return None
assert check_request({"host": "localhost:8000"}, 8000, []) is None
assert check_request({"host": "rebind.attacker.example:8000"}, 8000, []).startswith("host")
assert check_request({"host": "127.0.0.1:8000", "origin": "https://evil.example"}, 8000, []).startswith("origin")A runnable Node.js version with an HTTP server, a test suite covering every row of the table, and a README is in cassandra-mcp, examples/localhost-http-guard. It uses only the Node standard library.
Verification you can run in a minute
You do not need an attacker DNS server to test the guard; the server only sees headers. Send the requests a browser would send and check the status codes. A request with a spoofed Host should get 403, a request with a foreign Origin should get 403, and the plain loopback request should succeed.
Expected output: 200, then 403, then 403.
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/health
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: rebind.attacker.example:8000" http://127.0.0.1:8000/health
curl -s -o /dev/null -w "%{http_code}\n" -H "Origin: https://evil.example" -X POST http://127.0.0.1:8000/api/runRun the same three requests against every local agent or model server you use. The interesting results are on the routes nobody thinks of as an API: health checks, agent cards, streaming endpoints, WebSocket upgrades and static UI paths. The ADK Go change existed precisely because those routes had been left out.
Failure modes
- Checking Origin only, which leaves the rebinding GET open; the same-origin read is the one that leaks data.
- Substring or suffix matching on Host, which accepts attacker names that contain or end in an allowed word.
- Accepting
Origin: null, which sandboxed iframes and some redirects send; treat it as foreign. - Guarding the API router but not the root router, so health, streaming or UI routes stay open.
- Binding to 0.0.0.0 inside a dev container whose port is published to the host, which removes the loopback assumption entirely; add authentication.
- A wildcard escape hatch such as an allow-all flag left in a shared start script.
Scope
DNS rebinding is one route to local servers; a malicious package or browser extension already running locally needs other defences. The guard does not authenticate the user; a token or a Unix socket is still the stronger control for anything that can take actions. For agents that fetch URLs and could be steered at internal hosts, the opposite direction, see server-side request forgery through LLM tools.
Sources and scope
Checked against these sources on 20 October 2026. Examples and figures are illustrative, not measurements.
- google/adk-go release v2.6.0, 2026-10-13
- google/adk-go PR #1641: check Host and Origin on every web launcher route (merged 2026-10-02)
- MCP specification 2026-07-28: Streamable HTTP transport, security warning
- WHATWG Fetch Standard: append a request Origin header
- RFC 6454: The Web Origin Concept
- vllm-project/vllm PR #61009: restrict default CORS origins
Check Host against exact loopback names and Origin against an exact allowlist on every route of a local server, reject Origin null, bind to loopback and still authenticate.