Visual Companion Auth Hardening Design
Date: 2026-06-10 Status: Draft for Drew review
Goal
Fix the security and reliability gaps found in PR #1720's brainstorming visual companion without changing the companion's core workflow or adding runtime dependencies.
The fixes must be test-first and must leave clear automated evidence for:
- cross-origin browser tabs cannot inject companion events by riding cookies
- restart reconnect works without depending only on browser cookie behavior
- bearer keys do not remain in the visible URL after bootstrap
/files/*cannot serve files outside the content directory- future same-origin vendored UI libraries still work
Threat Model
The companion serves agent-generated local UI for a single brainstorming session. The important assets are:
- screen content served from the companion
- the session key
state/events, which the agent reads as user feedback- local files under the companion session directory
In scope attackers:
- a malicious browser tab on another
localhostport - a browser page that can make requests to the companion but should not be able to authenticate as the companion UI
- a direct remote client when the server is bound to a non-loopback interface
- accidental leakage through URL history, referrers, or committed local state
- content-directory symlinks or path tricks that escape
/files/*
Out of scope for this fix:
- malicious agent-authored screen HTML
- malicious same-origin vendored JavaScript loaded by a companion screen
This out-of-scope boundary is intentional. Companion screens are part of the agent UI surface. They may use inline scripts today and may someday use same-origin vendored libraries such as Alpine or Three.js. Protecting against malicious screen HTML would require a larger sandboxed-iframe architecture with a narrow message bridge; that is not the scope of this PR hardening pass.
Current Failures
Automated and headed-browser testing found these failures in the PR branch:
- A cross-origin localhost page can open a cookie-authenticated WebSocket and write attacker-controlled choices to
state/eventsafter the real companion page sets the cookie. /files/*serves symlinks that point outsidecontent/, including a symlink tostate/server-infocontaining the keyed URL.- The session key remains in the URL of the actual screen page, so same-origin screen JavaScript and accidental referrers/history can see it.
- The helper reconnects with a keyless
ws://hostURL. In headed Chrome, after a same-port/same-token restart, the browser stopped presenting the cookie to the restarted server, so the open tab stayed stuck on the tombstone until a manual reload. - Shell lint and the lifecycle test need cleanup so the test pass is stable in Codex.
Design
1. Bootstrap Keyed Loads
GET /?key=<token> becomes a bootstrap response, not the screen response.
When the key is valid, the server:
- sets the HttpOnly session cookie as it does today
- returns a small HTML bootstrap page
- the bootstrap page stores the key in tab-scoped
sessionStorage - the bootstrap page navigates to
/usinglocation.replace('/')
After this, the visible screen URL is bare /, not /?key=....
GET / with a valid cookie serves the current screen. GET / without a valid cookie still returns the friendly 403 page. GET /?key=<wrong> returns 403.
Why sessionStorage: the helper needs a reconnect credential that survives same-port restarts and does not depend only on cookie behavior. Because screen HTML is trusted same-origin UI, storing the key in tab-scoped storage is acceptable for this threat model. It is materially better than leaving the key in the address bar, history, and referrer surface.
2. WebSocket Same-Origin Enforcement
WebSocket upgrades must pass both checks:
- valid session auth by query key or cookie
- if an
Originheader is present, it must match the request target origin
The origin check should compare:
Origin === "http://" + req.headers.hostBrowser attacker page example:
Origin: http://localhost:9999
Host: localhost:58088This must be rejected even if the browser sends the companion cookie.
Legitimate companion page example:
Origin: http://localhost:58088
Host: localhost:58088This should be accepted when the key or cookie is valid.
Direct non-browser clients may omit Origin; they still need the session key.
3. Helper Reconnect Credential
helper.js should read the tab-scoped key from sessionStorage and append it to the WebSocket URL: