Skip to content

可视化伴侣认证加固设计

由 Markdown 原样翻译并转换为 Astro Starlight MDX 格式。

日期: 2026-06-10 状态: Draft for Drew 审查

Fix the 安全 and reliability gaps found in PR #1720’s brainstorming visual companion without changing the companion’s core 工作流 or adding 运行时 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
  • 重启 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 目录
  • future same-origin vendored UI libraries still work

The companion serves agent-generated 本地 UI for a single brainstorming session. The important assets are:

  • screen content served from the companion
  • the session key
  • state/events, which the agent 读取 as user feedback
  • 本地 files under the companion session 目录

范围内 attackers:

  • a malicious browser tab on another localhost port
  • a browser page that can make requests to the companion but should not be able to authenticate as the companion UI
  • a direct remote 客户端 when the 服务器 is bound to a non-loopback interface
  • accidental leakage through URL history, referrers, or committed 本地 state
  • content-directory symlinks or path tricks that escape /files/*

范围外 for this fix:

  • malicious agent-authored screen HTML
  • malicious same-origin vendored JavaScript 已加载 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 架构 with a narrow message bridge; that is not the 范围 of this PR 加固 pass.

Automated and headed-browser testing found these 失败 in the PR branch:

  1. A cross-origin localhost page can open a cookie-authenticated WebSocket and write attacker-controlled choices to state/events after the real companion page sets the cookie.
  2. /files/* serves symlinks that point outside content/, including a symlink to state/server-info containing the keyed URL.
  3. The session key remains in the URL of the actual screen page, so same-origin screen JavaScript and accidental referrers/history can see it.
  4. The helper reconnects with a keyless ws://host URL. In headed Chrome, after a same-port/same-token 重启, the browser stopped presenting the cookie to the restarted 服务器, so the open tab stayed stuck on the tombstone until a 手动 reload.
  5. Shell lint and the lifecycle test need 清理 so the test pass is stable in Codex.

GET /?key=<token> becomes a bootstrap response, not the screen response.

当 the key is valid, the 服务器:

  1. sets the HttpOnly session cookie as it does today
  2. returns a small HTML bootstrap page
  3. the bootstrap page stores the key in tab-scoped sessionStorage
  4. the bootstrap page navigates to / using location.replace('/')

After this, the visible screen URL is bare /, not /?key=....

GET / with a valid cookie serves the 当前 screen. GET / without a valid cookie still returns the 友好 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.

WebSocket upgrades must pass both checks:

  1. valid session auth by query key or cookie
  2. if an Origin header is present, it must match the request target origin

The origin check should compare:

Origin === "http://" + req.headers.host

Browser attacker page example:

Origin: http://localhost:9999
Host: localhost:58088

This must be rejected even if the browser sends the companion cookie.

Legitimate companion page example:

Origin: http://localhost:58088
Host: localhost:58088

This should be accepted when the key or cookie is valid.

Direct non-browser clients may omit Origin; they still need the session key.

helper.js should read the tab-scoped key from sessionStorage and append it to the WebSocket URL:

ws://<host>/?key=<stored-key>

如果 no stored key exists, the helper falls back to the 当前 cookie-only ws://<host> behavior. This preserves 兼容性 for already-loaded pages that do have a valid cookie but no storage entry.

The file 服务器 should continue to reject empty names and dotfiles. It must also ensure the file is a real regular file inside CONTENT_DIR.

使用 realpath containment as the boundary:

  • compute realContentDir = fs.realpathSync(CONTENT_DIR)
  • compute realFilePath = fs.realpathSync(filePath)
  • serve only when realFilePath equals a descendant of realContentDir
  • reject symlinks and anything outside the content 目录 with 404

The 服务器 should keep using path.basename so nested paths remain unsupported.

添加 conservative headers that do not block inline scripts or future same-origin vendored libraries:

Referrer-Policy: no-referrer
Cache-Control: no-store
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'
Cross-Origin-Resource-Policy: same-origin

Do not add a restrictive script-src CSP in this pass. The companion currently injects inline helper JavaScript and future screens may load same-origin vendored libraries.

添加 .superpowers/ to the repo root .gitignore so persisted companion state and .last-token are not accidentally committed when using --project-dir.

Clean up shell lint 警告 in the touched start/stop scripts.

更新 the lifecycle test that invokes start-server.sh --idle-timeout-minutes so it cannot hang under Codex’s CODEX_CI foreground auto-detection. The test should force 背景 mode with --background when it expects the script to return startup JSON.

All behavior changes should be TDD:

  1. write the failing focused test
  2. run it and confirm it fails for the expected reason
  3. implement the minimum fix
  4. rerun the focused test
  5. rerun the full brainstorm-server suite

Required focused regressions:

  • valid keyed / returns bootstrap, not screen content
  • bootstrap stores key in sessionStorage and strips the URL
  • cookie-only / still serves screen content
  • helper uses sessionStorage key for WebSocket URL
  • same-origin cookie WebSocket opens
  • cross-origin cookie WebSocket is rejected and writes no events
  • direct key WebSocket still opens without Origin
  • symlink under content/ pointing to state/server-info returns 404
  • 安全 headers are present on normal HTML, bootstrap, 403, and file responses
  • 重启 same port/token can authenticate reconnect with the stored key
  • shell lint passes for touched shell scripts
  • lifecycle suite does not hang under Codex
  • cd tests/brainstorm-server && npm test passes repeatedly without hanging.
  • The 安全 probe that previously wrote attacker-injected from another localhost origin now fails to open the WebSocket and leaves state/events unchanged.
  • The symlink-to-server-info probe returns 404.
  • A headed or headless browser keyed load ends on a bare / URL and the status pill reaches Connected.
  • A same-port/same-token 重启 reconnects 自动 without 手动 reload.
  • scripts/lint-shell.sh passes for the touched shell scripts.

如果 the 项目 later needs to treat screen HTML as untrusted, 设计 a separate sandboxed iframe architecture. That should isolate generated screens on a separate origin or sandboxed frame and expose only a narrow postMessage bridge for user choices. Do not bundle that into this fix.

-
0:000:00