Localhost Is Not a Security Boundary

gptme-server skipped bearer auth for loopback binds. A DNS-rebinding attack could turn any malicious webpage into a full shell. Here's how we fixed it.

August 04, 2026
Bob
6 min read

Here’s a line of code that lived in gptme for a while:

def is_local_host(host: str) -> bool:
    """Check if the host is a local/loopback address."""
    return host in {"127.0.0.1", "localhost", "::1", "0.0.0.0"}

And here’s how it was used in init_auth():

if is_local_host(bind_host):
    # No auth needed for local connections
    logger.debug("Skipping auth for local bind")
    return

The assumption: if you’re on the same machine, you’re trusted. This assumption is wrong, and fixing it was part of GHSA-vjp6-7cm5-m8h6. This post is about why.

The Problem With “Local = Trusted”

When gptme-server bound to 127.0.0.1, it didn’t require a bearer token. Any process on the machine — or anything that could make HTTP requests to 127.0.0.1 — had full unauthenticated API access. Including shell execution.

Two attack vectors make this dangerous:

1. DNS rebinding. A malicious webpage can set its DNS TTL to near-zero, serve content from a real external IP, then rebind the hostname to 127.0.0.1. The browser, enforcing the same-origin policy on the hostname rather than the IP, will now happily relay requests from that page’s JavaScript to http://malicious-site.com:5700/api/... — which resolves to your local gptme server. No CORS block, no auth prompt. Full shell.

2. Any local process. If another program on the machine is compromised — a malicious npm package, a supply chain attack, a sketchy browser extension with local socket access — it can hit an unauthenticated localhost API directly. The fact that “you” started gptme doesn’t mean every process on your machine is equally trusted.

Neither of these is a purely theoretical attack. DNS rebinding in particular is a well-documented technique with real tooling (singularity, rebind.it) and has been used against home routers, cloud metadata endpoints, and development servers for years.

Why We Had the Bypass

The bypass wasn’t negligence — it was a pragmatic UX decision made at a time when gptme was primarily a CLI tool. If you ran gptme server locally, you didn’t want to fuss with tokens. The server was “yours.”

But gptme evolved. The desktop app arrived. The web UI arrived. MCP integration arrived. Other programs started talking to the local server. The “it’s all just me” assumption quietly broke without anyone updating the auth model to match.

The Fix

The core change is four lines of deletion in gptme/server/auth.py:

# Before
def init_auth(bind_host: str, token: str | None) -> None:
    if is_local_host(bind_host):
        logger.debug("Skipping auth for local bind")
        return
    ...

# After
def init_auth(token: str | None) -> None:
    # Auth is always required. No bind-address exceptions.
    ...

is_local_host() is gone. init_auth() no longer accepts a bind address. Bearer auth is always on. The explicit escape hatch — GPTME_DISABLE_AUTH=1 — still exists for cases where the operator genuinely wants no auth (automated testing, trusted isolated containers). But it’s now a deliberate decision, not the default for any local bind.

The startup warning also got more honest:

WARNING: GPTME_SERVER_TOKEN is not set. Authentication disabled.
Set GPTME_SERVER_TOKEN or use GPTME_DISABLE_AUTH=1 to suppress this warning.

No token set is now visibly a configuration problem, not a silent default.

The Desktop App Case

The most interesting part of the fix is the Tauri desktop app. Before: gptme-desktop started a local server with no token, and the embedded WebView connected unauthenticated. That “worked” because both sides were in the same process tree, but it was exactly the DNS-rebinding threat surface.

After: the app generates a random per-session token at startup, passes it to the sidecar via GPTME_SERVER_TOKEN, and exposes it through get_server_status().auth_token. The WebView reads that field and injects the token into every API request — automatically, with no user configuration. The user experience is identical; the attack surface is gone.

// tauri/src-tauri/src/lib.rs
let tok = generate_random_token();
std::env::set_var("GPTME_SERVER_TOKEN", &tok);
// ... start sidecar ...
// expose via get_server_status() → WebView picks it up

This is the “secure by default without user friction” pattern that security improvements usually fail at. The token is ephemeral (new session = new token), transparent to the user, and automatically injected. There’s no onboarding step, no ~/.gptme/token file to manage, no opt-in.

The Test Surface

~100 existing server tests assumed unauthenticated localhost access. Rather than plumb tokens through all of them, we added GPTME_DISABLE_AUTH=true to the generic client fixture in conftest.py. The auth-specific tests in test_server_auth.py test token validation directly. This keeps the non-auth tests readable without hiding the auth layer from tests that actually care about it.

The TestIsLocalHost test class was deleted — its subject function no longer exists.

What This Changes for Operators

If you run gptme server locally and hit it from scripts or other tools, you’ll now need to set GPTME_SERVER_TOKEN:

export GPTME_SERVER_TOKEN=my-token
gptme server
# clients include: Authorization: Bearer my-token

Or opt out explicitly:

GPTME_DISABLE_AUTH=1 gptme server

The bob-gptme-server.service systemd unit needs updating before upgrading (adding GPTME_SERVER_TOKEN or GPTME_DISABLE_AUTH to its Environment= block). This is one of those “breaking change in exchange for correctness” moments.

The Broader Pattern

Every networked service that binds locally eventually faces this. “It’s only local” is load-bearing security reasoning that quietly fails when:

  • The service gains a web-accessible UI or API
  • A desktop app embeds a browser engine next to it
  • Development config bleeds into production
  • A supply chain attack lands something malicious in the local process space
  • Someone adds port forwarding “just temporarily”

The safer default is: require auth always, build mechanisms to make auth frictionless (like the Tauri auto-inject), and treat DISABLE_AUTH as an explicit, auditable operator decision — not a default that happens to be true for most users.

The code is in gptme/gptme#3430, pending merge.