Voicebox documentation

Local Browser Authentication Options for Loopback Endpoints

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.


1. The Loopback Transport Constraint

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.

Why Embedding a Token in Unauthenticated HTML Is Ineffective

Injecting an ephemeral session token into openly served HTML (for example, <meta name="voicebox-page-token" content="...">) does not prevent local scripts from connecting:

  1. Readable Over Loopback: If the HTML page is served without authentication, any local script can fetch the page over HTTP, extract the token, and pass it on a WebSocket connection.
  2. The Single-Use Dilemma:
    • Multi-use token: A local script fetches the HTML once and reuses the token.
    • Single-use token: A local process can race the browser at startup, consume the single-use token first, and lock the user out (or break normal browser page reloads).

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.


2. Architectural Options Evaluated

Option A: Host-Token Bootstrap Ticket (Implemented as Opt-In)

Established by tools such as Jupyter Notebook and VS Code Server (code-server):

  1. When VOICEBOX_LOOPBACK_AUTH=1 is set, the server does not serve HTML or API routes without an authenticated session.
  2. At startup, the server mints a single-use cryptographic bootstrap ticket and prints the launch URL (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).
  3. Navigating to /?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.
  4. Subsequent HTTP, REST API, and WebSocket (/channel, /live) requests require either the session cookie or x-voicebox-host-token.

Option B: Browser-Generated WebCrypto Key & Console Pairing

  1. On first load, the browser generates a non-extractable ECDSA P-256 keypair (crypto.subtle.generateKey(..., false, ["sign", "verify"])), stores it in IndexedDB, and displays a pairing code.
  2. The user confirms the pairing code in the terminal using .host-token.
  3. On each connection, the browser signs a server challenge nonce with its non-extractable private key.

3. Comparison Matrix

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

4. Decision & Implementation

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:

  1. Consistent Authority Model: Reuses the existing 0600 .host-token boundary already used for /api/root, /api/pair, and extension admission.
  2. Multi-User & Container Isolation: Prevents other OS accounts or unprivileged containers sharing loopback from accessing the server or WebSocket endpoints.
  3. Full Implementation Details: See 18-loopback-session-auth.md and tests/loopback-auth.test.mjs.