DEV Community

Cover image for Why our MCP server stopped trusting localhost by default
Alex
Alex

Posted on Fully Autonomous

Why our MCP server stopped trusting localhost by default

The backend sees 127.0.0.1. The authorization code decides that the request belongs to the person running the server.

A local reverse proxy breaks that inference. Its connection to the backend is local even when the request that caused it came from somewhere else.

We maintain AIMarket MCP Gateway. In version 0.6.3, our keyless HTTP path tried to identify a local caller using its socket and operating-system user. Vulnetix pointed out the remaining gap: a proxy running as that same user could satisfy those checks. If the Linux socket tables were unavailable, the ownership check also returned success.

In 0.6.4, released on 28 October, ordinary HTTP RPC requests without a configured key are refused by default, including direct loopback requests. Here is the small reconstruction that makes the reason visible.

The backend sees the last connection

Consider a development tool bound to loopback, with a relay on the same machine:

A remote caller reaches a local relay, which opens a new loopback connection to the backend. A direct local client also reaches the backend from loopback.

Deployment model. The relay needs no special ability to forge an address: it opens a new connection. The executable example below keeps every listener and client on loopback.

The backend can inspect its TCP peer. In Python's BaseHTTPRequestHandler, that address is available through client_address.

A deliberately simplified version of the mistake is:

allowed = ip_address(self.client_address[0]).is_loopback
Enter fullscreen mode Exit fullscreen mode

That condition describes the connection. Once it authorizes a private operation, a relay can carry someone else's request through it.

Our old code had more conditions than this one line: loopback Host and peer checks, browser Origin checks, forwarding-header rejection, and socket ownership. Those checks cover particular paths. They still cannot distinguish a direct client from an arbitrary relay owned by the same user.

A relay can construct the upstream Host, omit Origin and forwarding headers, and open the backend socket under its own user. A process name does not establish who requested the operation either.

Eight requests with an ordinary HTTP relay

I made a small standard-library example with two interchangeable backend policies:

  1. Accept any loopback peer.
  2. Require the expected Bearer credential.

The relay forwards a fixed /private request to a fixed loopback target. It copies an Authorization header only when the client supplied one. It never supplies its own backend credential to anonymous callers.

Every listener uses 127.0.0.1 and an automatically allocated port. The key is generated in memory for this run and never printed. Running the example produced:

Eight requests in the local reconstruction: the peer-only gate accepts two requests without keys. The Bearer gate rejects missing and wrong keys on both paths, and accepts the valid key on both paths. Every backend peer address is 127.0.0.1.

These are results from the educational HTTP reconstruction. It does not run the MCP package or reproduce a remote attack against a deployment.

The backend peer address remains 127.0.0.1 in all eight requests. Changing the authorization policy changes which requests succeed.

Reproduce it locally

Save the following as localhost_auth_demo.py and run python3 localhost_auth_demo.py. It uses Python 3.9 or later, needs no packages, serves no files and contacts no external host. Assertions check all eight responses, and both servers shut down after each policy.

Complete Python reconstruction

"""Educational HTTP reconstruction; fixed localhost targets, no MCP dependency."""
from contextlib import contextmanager
from hmac import compare_digest
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from ipaddress import ip_address
import json
import secrets
from threading import Thread


@contextmanager
def serve(handler):
    server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        yield server.server_port
    finally:
        server.shutdown()
        server.server_close()
        thread.join(timeout=2)


class QuietHandler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass  # No request or credential logging in this demonstration.


def backend_handler(require_key, key):
    class Backend(QuietHandler):
        def do_GET(self):
            if self.path != "/private":
                self.send_error(404)
                return
            peer = self.client_address[0]
            supplied = self.headers.get("Authorization", "").encode("utf-8")
            accepted = (
                compare_digest(supplied, f"Bearer {key}".encode("ascii"))
                if require_key else ip_address(peer).is_loopback
            )
            payload = json.dumps({"accepted": accepted, "peer": peer}).encode()
            self.send_response(200 if accepted else 401)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(payload)))
            self.end_headers()
            self.wfile.write(payload)
    return Backend


def relay_handler(upstream_port):
    class Relay(QuietHandler):
        def do_GET(self):
            # Fixed upstream/path; no URL supplied by the caller.
            headers = {}
            if "Authorization" in self.headers:
                headers["Authorization"] = self.headers["Authorization"]
            conn = HTTPConnection("127.0.0.1", upstream_port, timeout=3)
            try:
                conn.request("GET", "/private", headers=headers)
                response = conn.getresponse()
                payload = response.read()
                self.send_response(response.status)
                self.send_header("Content-Type", "application/json")
                self.send_header("Content-Length", str(len(payload)))
                self.end_headers()
                self.wfile.write(payload)
            finally:
                conn.close()
    return Relay


def request(port, key=None):
    conn = HTTPConnection("127.0.0.1", port, timeout=3)
    try:
        headers = {"Authorization": f"Bearer {key}"} if key is not None else {}
        conn.request("GET", "/private", headers=headers)
        response = conn.getresponse()
        return response.status, json.loads(response.read())
    finally:
        conn.close()


def main():
    key = secrets.token_urlsafe(32)
    results = []
    for mode, require_key in (("peer-only", False), ("bearer", True)):
        with serve(backend_handler(require_key, key)) as backend:
            with serve(relay_handler(backend)) as relay:
                cases = [("direct, no key", backend, None, 401 if require_key else 200),
                         ("relay, no key", relay, None, 401 if require_key else 200)]
                if require_key:
                    cases += [("direct, wrong key", backend, "wrong-demo-key", 401),
                              ("relay, wrong key", relay, "wrong-demo-key", 401),
                              ("direct, valid key", backend, key, 200),
                              ("relay, valid key", relay, key, 200)]
                for label, port, credential, expected in cases:
                    status, body = request(port, credential)
                    assert status == expected, (mode, label, status)
                    assert body["peer"] == "127.0.0.1", body
                    assert body["accepted"] == (status == 200), body
                    results.append({"gate": mode, "path": label,
                                    "status": status, "backend_peer": body["peer"]})
    print(json.dumps({"scope": "educational HTTP reconstruction; all listeners and clients use IPv4 loopback",
                      "checks": len(results), "results": results}, indent=2))


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The credential check in the example uses compare_digest. That makes the comparison appropriate for a secret; it does not turn this teaching server into a production authentication system. Python's own documentation describes the limits of http.server.

What we changed in the real package

The published 0.6.4 wheel changes the default for the HTTP RPC POST handler:

Configuration RPC behavior
AIMARKET_MCP_KEY set Require the matching Bearer credential.
No key, ordinary defaults Refuse the RPC request, including on loopback.
AIMARKET_MCP_LOCAL_RELAY=1, no key In development mode, explicitly trust eligible local TCP callers, including relays owned by the server's user. Other local checks still apply.
No Linux socket tables in that opt-in mode Also require AIMARKET_MCP_LOCAL_UNVERIFIED=1.
AIMARKET_MCP_PUBLIC=1, no key Explicitly allow anonymous RPC.

The operator supplies the key before starting the process. The package does not generate it automatically. Public mode also applies in production; it is a deliberate choice. A configured key takes precedence over public mode.

This change concerns HTTP RPC authorization. /health remains public, and stdio does not acquire an HTTP key requirement.

The Vulnetix record was still published without a fixed version listed when checked on 28 October. We inspected both published wheels and confirmed the code change; we are not claiming the researcher has verified 0.6.4 or closed the record.

Where to put the authorization decision

Keep a private backend on loopback when that fits the deployment. Then decide explicitly which component authenticates each request. A backend credential, a properly configured authenticating proxy, or an appropriately protected local IPC channel can enforce the intended policy. Each arrangement has its own trust assumptions.

If a proxy authenticates users and then presents one shared backend credential, that proxy holds the authorization boundary. Anyone who can use that forwarding path must pass its access checks. Otherwise the credential simply carries the anonymous caller further.

Forwarded address headers help recover client addresses only when they come from a configured trusted proxy. The resulting address still needs an authorization policy. Browser Origin checks address another part of the problem; arbitrary HTTP clients can construct their own headers.

A Bearer credential also has a precise limit: possession grants access. It does not identify a human by itself, and it will not protect a secret from a compromised process that can read that secret. Protect the credential, avoid logging it, and keep privileges narrow. For an interoperable MCP authorization deployment, use the protocol's authorization specification rather than treating this small example as an OAuth implementation.

The practical test I would add to any local HTTP tool is straightforward: put the relay you actually deploy in front of it, send a request without a credential, and assert that the private operation is refused. Keep that test when changing launchers, tunnels or proxy configuration.

Disclosure: we build AIMarket MCP Gateway, HISTOR and WARDEN. AI assistance was used for the code, writing and cover illustration. The two explanatory figures were rendered from code; the results figure reads the saved output of the local reconstruction. The cover is an editorial metaphor.

Top comments (0)