Dev.to Security 🔐 Cybersecurity 👁 0 📖 4 min read

Binding to 127.0.0.1 is not enough: keeping a local control API away from the web pages in your own browser

Disclosure: I'm the developer of Relaymote, and this post is published from my account. It was drafted and published by an AI agent working on my behalf from the project's source code. Relaymote is unofficial; not affili

Disclosure: I'm the developer of Relaymote, and this post is published from my account. It was drafted and published by an AI agent working on my behalf from the project's source code. Relaymote is unofficial; not affiliated with or endorsed by Anthropic.

Relaymote is an open-source (MIT) companion for Claude Code sessions on your PC. It runs a small daemon with a control API on 127.0.0.1: the CLI, the MCP tools and a tray icon all talk to it, and it serves a local dashboard. Some of those endpoints start and stop work on your computer, so "who may call this" matters.

The usual answer is "it only listens on loopback, so only this machine can reach it". That is true, and it is not the whole threat model. This post is the 38-line guard that closes the rest.

What loopback does not stop

Binding to 127.0.0.1 keeps other machines out. It does not keep out a web page running in your own browser on this machine. Two ways that goes wrong:

  1. Blind cross-site requests. Any site you visit can make your browser send POST http://127.0.0.1:<port>/api/task with a form or fetch(..., {mode: 'no-cors'}). The page cannot read the response, but it does not need to: the request already did its work.
  2. DNS rebinding. A site can point its own hostname at 127.0.0.1 after the page loads. The browser now treats http://evil.example:<port> as same-origin with the page, and the page can send requests and read the answers. The only trace on the server side is the Host header, which says evil.example:<port>.

CORS does not help with either. CORS controls who may read a response; it does not stop a simple POST from arriving, and under rebinding the browser thinks there is no cross-origin request at all.

What the legitimate callers look like

The fix starts with noticing that the real callers are easy to describe:

  • The CLI, MCP tools and tray are not browsers. They send no Origin header, and they address the port as 127.0.0.1, localhost or [::1].
  • The dashboard is a browser page, but it is served by the daemon itself, so its Origin is a loopback address on this same port.

Everything else is someone else.

The guard

const LOOPBACK = ['127.0.0.1', 'localhost', '[::1]'];

function allowedHost(value, port) {
  const v = String(value || '').trim().toLowerCase();
  return LOOPBACK.some(h => v === `${h}:${port}`);
}

/** Returns null when the request may proceed, or { status, body } to refuse it. */
function check(req, port) {
  const h = req.headers || {};
  if (h.host !== undefined && !allowedHost(h.host, port)) {
    return { status: 403, body: { ok: false, error: 'BAD_HOST' } };
  }
  const safe = req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS';
  if (safe) return null;
  if (String(h['sec-fetch-site'] || '').toLowerCase() === 'cross-site') {
    return { status: 403, body: { ok: false, error: 'CROSS_ORIGIN' } };
  }
  if (h.origin !== undefined) {
    let ok = false;
    try { const o = new URL(String(h.origin)); ok = o.protocol === 'http:' && allowedHost(o.host, port); } catch {}
    if (!ok) return { status: 403, body: { ok: false, error: 'CROSS_ORIGIN' } };
  }
  return null;
}

It runs first in the request handler, before any routing:

const refused = require('./lib/control-guard').check(req, PORT);
if (refused) return json(res, refused.status, refused.body);

Three rules, in order:

  • Host must be loopback on this port, for every method. This is the rebinding defence, so it applies to GET too: a rebinding page reading /api/state is a leak even if it changes nothing. The comparison is exact (localhost:<port>, not "contains localhost"), so localhost.evil.example does not pass.
  • Safe methods stop there. GET, HEAD and OPTIONS change nothing, and the dashboard loads with them.
  • State-changing methods must not come from another site. Sec-Fetch-Site: cross-site is refused outright. If an Origin is present, it must parse as http: on a loopback host and this port. A missing Origin passes, because that is what the CLI looks like.

The edge cases the tests pin down

The test suite checks the cases that are easy to get wrong:

G('POST', { host: `127.0.0.1:${PORT}` })                                  // CLI / MCP: passes
G('POST', { host: `127.0.0.1:${PORT}`, origin: `http://127.0.0.1:${PORT}` }) // dashboard: passes
G('POST', { host: `127.0.0.1:${PORT}`, origin: 'https://evil.example' })  // CROSS_ORIGIN
G('POST', { host: `127.0.0.1:${PORT}`, origin: 'null' })                  // CROSS_ORIGIN
G('GET',  { host: `evil.example:${PORT}` })                               // BAD_HOST, even for GET
G('POST', { host: `localhost:${PORT}`, 'sec-fetch-site': 'cross-site' })  // CROSS_ORIGIN

Origin: null deserves a note. Sandboxed iframes, file:// pages and some redirects send the literal string null. It is not "no origin"; it is a browser telling you it will not say. new URL('null') throws, ok stays false, and the request is refused.

Two cheap extras on the dashboard

The dashboard response is sent with X-Frame-Options: DENY (so another page cannot frame it and trick clicks onto its buttons) and Cache-Control: no-store. Neither replaces the guard; both are one header each.

What this does not cover

It is not authentication. Any program already running as you on the same machine can call the API, exactly as it could read your files. The guard's job is narrower: a web page should not get the powers of a local program just because the browser happens to run on that machine. For a localhost API, that narrow job is the one most often skipped.

If you ship a local daemon with an HTTP control port (dev servers, desktop helpers, editor plugins), the checklist is short: exact loopback Host on every request, refuse Sec-Fetch-Site: cross-site on writes, and allow only your own loopback Origin or none.

Relaymote is on GitHub under the MIT licence. Unofficial; not affiliated with or endorsed by Anthropic.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.