{"slug": "binding-to-127-0-0-1-is-not-enough-keeping-a-local-control-api-away-from-the-web", "title": "Binding to 127.0.0.1 is not enough: keeping a local control API away from the web pages in your own browser", "summary": "A developer released Relaymote, an MIT-licensed open-source companion daemon for Claude Code sessions, along with a 38-line request guard that blocks browser-based attacks against its local control API on 127.0.0.1. The guard rejects requests whose Host header isn't an exact loopback match, refuses non-safe methods flagged Sec-Fetch-Site: cross-site, and validates Origin, closing DNS-rebinding and no-cors POST paths that CORS alone does not stop. The developer notes the project is unofficial and not affiliated with Anthropic.", "body_md": "*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.*\n\n[Relaymote](https://github.com/ashwarsadh/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.\n\nThe 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.\n\nBinding 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:\n\n`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.`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 `Host` header, which says `evil.example:<port>`.\nCORS 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.\n\nThe fix starts with noticing that the real callers are easy to describe:\n\n`Origin` header`localhost` or `[::1]`.\nEverything else is someone else.\n\n``` js\nconst LOOPBACK = ['127.0.0.1', 'localhost', '[::1]'];\n\nfunction allowedHost(value, port) {\n  const v = String(value || '').trim().toLowerCase();\n  return LOOPBACK.some(h => v === `${h}:${port}`);\n}\n\n/** Returns null when the request may proceed, or { status, body } to refuse it. */\nfunction check(req, port) {\n  const h = req.headers || {};\n  if (h.host !== undefined && !allowedHost(h.host, port)) {\n    return { status: 403, body: { ok: false, error: 'BAD_HOST' } };\n  }\n  const safe = req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS';\n  if (safe) return null;\n  if (String(h['sec-fetch-site'] || '').toLowerCase() === 'cross-site') {\n    return { status: 403, body: { ok: false, error: 'CROSS_ORIGIN' } };\n  }\n  if (h.origin !== undefined) {\n    let ok = false;\n    try { const o = new URL(String(h.origin)); ok = o.protocol === 'http:' && allowedHost(o.host, port); } catch {}\n    if (!ok) return { status: 403, body: { ok: false, error: 'CROSS_ORIGIN' } };\n  }\n  return null;\n}\n```\n\nIt runs first in the request handler, before any routing:\n\n``` js\nconst refused = require('./lib/control-guard').check(req, PORT);\nif (refused) return json(res, refused.status, refused.body);\n```\n\nThree rules, in order:\n\n`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.`HEAD` and `OPTIONS` change nothing, and the dashboard loads with them.`Sec-Fetch-Site: cross-site` is refused outright. If an `http:` on a loopback host and this port. A missing The test suite checks the cases that are easy to get wrong:\n\n```\nG('POST', { host: `127.0.0.1:${PORT}` })                                  // CLI / MCP: passes\nG('POST', { host: `127.0.0.1:${PORT}`, origin: `http://127.0.0.1:${PORT}` }) // dashboard: passes\nG('POST', { host: `127.0.0.1:${PORT}`, origin: 'https://evil.example' })  // CROSS_ORIGIN\nG('POST', { host: `127.0.0.1:${PORT}`, origin: 'null' })                  // CROSS_ORIGIN\nG('GET',  { host: `evil.example:${PORT}` })                               // BAD_HOST, even for GET\nG('POST', { host: `localhost:${PORT}`, 'sec-fetch-site': 'cross-site' })  // CROSS_ORIGIN\n```\n\n`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.\n\nThe 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.\n\nIt 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.\n\nIf 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.\n\nRelaymote is on [GitHub](https://github.com/ashwarsadh/relaymote) under the MIT licence. Unofficial; not affiliated with or endorsed by Anthropic.", "url": "https://wpnews.pro/news/binding-to-127-0-0-1-is-not-enough-keeping-a-local-control-api-away-from-the-web", "canonical_source": "https://dev.to/ashwar_sadh_f7abe6f82c8e9/binding-to-127001-is-not-enough-keeping-a-local-control-api-away-from-the-web-pages-in-your-own-276", "published_at": "2026-11-05 06:17:27+00:00", "updated_at": "2026-11-05 06:23:42.207569+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-agents", "ai-safety"], "entities": ["Relaymote", "Claude Code", "Anthropic"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/binding-to-127-0-0-1-is-not-enough-keeping-a-local-control-api-away-from-the-web", "markdown": "https://wpnews.pro/news/binding-to-127-0-0-1-is-not-enough-keeping-a-local-control-api-away-from-the-web.md", "text": "https://wpnews.pro/news/binding-to-127-0-0-1-is-not-enough-keeping-a-local-control-api-away-from-the-web.txt", "jsonld": "https://wpnews.pro/news/binding-to-127-0-0-1-is-not-enough-keeping-a-local-control-api-away-from-the-web.jsonld"}}