Both /live and /channel validate the request Origin against local loopback origins (127.0.0.1, localhost, [::1]) and require a cryptographic pairing bearer (vbx_...) for non-local peers. Because browsers enforce immutable Origin headers on WebSocket handshakes, this closes Cross-Site WebSocket Hijacking (CSWSH) from external web pages. On the Vite dev front (localhost:5173), the WebSocket proxy verifies that incoming connections originate from the dev front itself before rewriting Origin to the API target, preserving CSWSH protection across the development proxy (voicebox-beads-efc6). Browsing the dev front via non-loopback LAN or Tailscale IP literals (e.g. http://192.168.x.x:5173, http://100.x.y.z:5173) forwards the non-local Origin without rewriting; such clients are treated as remote peers by server.mjs, requiring pairing bearer authentication in the initial hello JSON frame ({"type":"hello","bearer":"vbx_..."}) from a paired environment, or access via an allowed Tailscale Serve hostname (*.ts.net, voicebox-beads-vc1h, voicebox-beads-ocoj).
For HTTP routes, the bounded JSON reader rejects browser-simple untyped mutations (415), while server.mjs checks browser Origin plus Fetch Metadata before dispatching bodyless POST /api/undo, POST /api/probe, cache-filling GET /api/probe, and bespoke-parser POST /api/mini-app/tools (403 cross-origin-request-refused, voicebox-beads-1la5/3bwx). Vite’s /api dev proxy rewrites Origin, forwards Sec-Fetch-Site, and stamps the incoming browser Origin into x-voicebox-proxied-origin (overwriting any client-supplied copy). The guard refuses when that original origin’s host differs from the browser-addressed Host; the extra header is negative-only, not a credential. This also preserves refusal of hostile POSTs from older browsers without Fetch Metadata through the dev front. This HTTP check does not change the separate /channel or /live WebSocket proxy hops. Own-page and unheaded machine clients remain usable. These browser-only signals do not establish local-process identity: an old cross-site GET with neither header remains indistinguishable from a machine call.
A source-seam unit check (tests/csrf-route-seam.test.mjs, voicebox-beads-deo7/3zpy/cegk/zfrh) inventories the host’s mutating routes — any verb that is not a read (GET/HEAD/OPTIONS), so POST, PUT, PATCH and DELETE alike — in the route table and the inline dispatcher. It reads the dispatcher as statements and, inside each, as top-level || clauses: a clause that names an /api/… path and claims a verb derives a route key — whatever its spelling. Single- or double-quoted literals, a non-read guard (req.method !== "GET") that admits every mutating verb at once (keyed MUTATION /api/…), a branch inserted above the browser guard, a single-quoted table key, a method test held in a variable (const isPost = req["method"].toUpperCase() === "POST"), a path matched into a variable (const envDelete = url.pathname.match(/^\/api\/environments\/([^/]+)$/), keyed /api/environments/<param>), and a pathname route with no method test at all each derive their route and fail unless it has a decision, so none of them can enter the host silently (voicebox-beads-3zpy hardened the spellings and voicebox-beads-cegk widened the verbs after bodyless PUT/PATCH/DELETE routes — and every table key that was not POST — stayed green). The verb is read as a verb, not as a token in one case: === "post", req.method.toLowerCase() === "post" and != "get" are classified like their upper-case forms, and a method comparison the seam cannot read at all is named as a complaint instead of being skipped (voicebox-beads-zfrh — the strict-vs-loose == gap it was filed for was already refused on the widened branch, and the holes that survived were all case). Each derived route must keep its declared mechanism: a JSON reader (readJson/readJsonBody), a host-token check (hostTokenOk), an extension-authority check (hasExtensionAuthority, the two <param> keys), or an entry in the browser-sensitive guard. A claim that names no route fails unless it is one of the named internal uses (the /api/probe force flag and four privileged environment-surface branches under the extension-authority gate). Eighteen synthetic evasions make that a tested property rather than a claim, and a GET/HEAD-only route is the control arm that changes nothing. The inventory floor is the decision count, so a scan that stops seeing routes it can still name fails rather than passing quietly, and a decision whose handler or route no longer exists fails too. This is a check on those host shapes, not a JavaScript parser: it does not claim the route groups extracted to their own tables (lib/routes/*.mjs, extensionRoutes) are covered. Review those separately when adding routes.
This document analyzes the local loopback process boundary: what a non-browser process running on the same machine can do, why embedding a token inside unauthenticated HTML does not add security, and how the opt-in bootstrap session gate (VOICEBOX_LOOPBACK_AUTH=1) works.
Over loopback TCP (127.0.0.1), the operating system kernel does not attach browser identity to a socket. Any process running on the same machine (under the same user or a shared network namespace) can open a TCP socket and send Origin: http://127.0.0.1:8787.
Injecting an ephemeral session token into openly served HTML (for example, <meta name="voicebox-page-token" content="...">) does not prevent local scripts from connecting:
To distinguish the authorized user’s browser session from an unprivileged local process, the initial credential must be delivered out-of-band rather than embedded in unauthenticated HTML.
Established by tools such as Jupyter Notebook and VS Code Server (code-server):
VOICEBOX_LOOPBACK_AUTH=1 is set, the server does not serve HTML or API routes without an authenticated session.http://127.0.0.1:8787/?bootstrap=<ticket>) to stdout (or mints one on demand via POST /api/bootstrap authenticated with x-voicebox-host-token)./?bootstrap=<ticket> redeems the ticket once, sets an HttpOnly, SameSite=Strict session cookie (vb_session), and answers 303 to the same route with the ticket removed — so the address bar is left holding the plain page a refresh can re-request./channel, /live) requests require either the session cookie or x-voicebox-host-token.crypto.subtle.generateKey(..., false, ["sign", "verify"])), stores it in IndexedDB, and displays a pairing code..host-token.| Property | Default Baseline (Origin Check) |
Option A: Bootstrap Ticket (VOICEBOX_LOOPBACK_AUTH=1) |
Option B: WebCrypto Pairing |
|---|---|---|---|
| Cross-site browser attacks (CSWSH) | Blocked (Origin header) |
Blocked (Cookie + Origin) |
Blocked (Key signature) |
| Other OS users / containers on shared loopback | Unblocked | Blocked (.host-token mode 0600) |
Blocked (Console pairing) |
| Same-UID local scripts | Unblocked | Unblocked (can read .host-token) |
Blocked (Non-extractable key) |
Direct URL launch (http://localhost:8787) |
Yes (zero friction) | Requires bootstrap URL on first load | Requires one-time console pairing |
| Failure recovery | Reload page | Open printed URL or call POST /api/bootstrap |
Re-pair if IndexedDB is cleared |
Voicebox implements Option A (Host-Token Bootstrap Ticket) as an opt-in security gate (VOICEBOX_LOOPBACK_AUTH=1), while keeping the zero-friction Origin-validated baseline as the default for single-user workstations:
0600 .host-token boundary already used for /api/root, /api/pair, and extension admission.18-loopback-session-auth.md and tests/loopback-auth.test.mjs.