Superpowers 视觉伴侣认证加固:Bootstrap Key 加载、WebSocket 同源校验与 /files 目录逃逸防护设计解析
本文以 Superpowers(brainstorming 技能的本地 Visual Companion 服务器)的认证加固设计文档为主体,完整还原其威胁模型、五项安全缺陷与七项修复设计(Bootstrap Keyed Loads、WebSocket 同源强制、Helper 重连凭据、/files/* 目录收敛、泄密削减响应头、.gitignore 持久状态、测试稳定性),并结合 server.cjs、helper.js 与 auth.test.js 等源码与测试,逐条印证每项设计在仓库中的落地方式。读完后,你可以掌握一套“本地单会话 UI 服务器”的完整认证加固方案:如何在不改变核心工作流、不引入运行时依赖的前提下,让 key 不落地址栏、跨源 tab 无法注入事件、符号链接无法逃逸内容目录。
背景与目标:不改工作流的认证加固
Visual Companion 是 Superpowers 中 brainstorming 技能(见 SKILL.md)的本地配套服务:agent 在头脑风暴会话中把生成的 HTML 屏幕写入 content/ 目录,用户浏览器打开带 key 的 URL 查看屏幕并点击选项,选择事件经 WebSocket 写入 state/events 供 agent 读取。它本质上是“运行在本机的、面向单个头脑风暴会话的本地 UI 服务器”,默认绑定 loopback,也可通过 --host 绑定到非回环接口。
设计文档 2026-06-10-visual-companion-auth-hardening-design.md 开篇给出的目标非常克制:
- 修复 PR #1720 的 visual companion 中发现的安全与可靠性缺口;
- 不改变 companion 的核心工作流;
- 不新增运行时依赖(服务器是零依赖的 Node 实现,WebSocket 协议 在 server.cjs 中手工实现);
- 所有修复必须 test-first,并留下自动化证据,覆盖以下五条:
- 跨源的浏览器 tab 不能“搭 cookie 的便车”向 companion 注入事件;
- 重启后的重连不能只依赖浏览器 cookie 行为;
- bootstrap 之后 bearer key 不得残留在可见 URL 中;
/files/*不得提供内容目录之外的文件;- 未来同源自托管(vendored)的 UI 库仍然可用。
威胁模型:哪些资产要保护,哪些攻击者要防
文档明确列出了要保护的关键资产:
- companion 提供的屏幕内容;
- 会话 key(session key);
state/events——agent 将其作为用户反馈读取,注入这里的恶意选择等于向活着的 agent 会话做提示注入;- companion 会话目录下的本地文件。
范围内的攻击者包括:
- 另一个
localhost端口上的恶意浏览器 tab; - 能对 companion 发起请求、但不应能冒充 companion UI 进行认证的浏览器页面;
- 服务器绑定到非回环接口时的直接远程客户端;
- 通过 URL 历史、Referer 或提交进版本库的本地状态造成的意外泄漏;
- 内容目录下的符号链接或路径技巧逃逸
/files/*。
文档同时刻意划出了范围外边界:agent 编写的恶意屏幕 HTML,以及屏幕加载的同源 vendored 恶意 JavaScript。理由是 companion 屏幕本身就是 agent 的 UI 表面,今天可以用内联脚本,将来可能用 Alpine、Three.js 这类同源 vendored 库;要防御恶意屏幕 HTML 需要一套独立的沙箱 iframe 架构加窄 postMessage 桥,那不属于本次加固范围(见下文“延后工作”)。这个边界声明对理解后续所有设计决策都很关键——例如为什么响应头故意不加 script-src CSP。
现状缺陷:五项已复现的失败
设计文档基于自动化测试与有头浏览器测试,在修复分支上确认了五个具体失败:
- 跨源 cookie 注入:另一个 localhost 端口的跨源页面,可以在真正的 companion 页面设置 cookie 之后,打开一个携带 cookie 的 WebSocket,把攻击者控制的选择写入
state/events; - 符号链接逃逸:
/files/*会提供指向content/之外的符号链接,其中包括指向state/server-info(内含带 key 的 URL)的链接; - key 残留在地址栏:真实屏幕页面的 URL 上带着会话 key,同源屏幕脚本和意外 Referer/历史都能看到它;
- 无 key 重连:helper 用不带 key 的
ws://hostURL 重连;在有头 Chrome 中,同端口/同 token 重启后,浏览器不再向重启后的服务器出示 cookie,打开的 tab 卡在 tombstone(“Companion paused”遮罩)上,只能手动刷新; - 测试不稳定:shell lint 与生命周期测试在 Codex 环境下需要清理,才能让测试通过保持稳定。
设计一:Bootstrap Keyed Loads——key 只走一次,然后从 URL 消失
这是本次加固的核心机制:GET /?key=<token> 从“直接返回屏幕”变为“返回 bootstrap 响应”。
服务器在 key 有效时按如下顺序处理:
- 像今天一样设置 HttpOnly 会话 cookie;
- 返回一个小的 HTML bootstrap 页;
- bootstrap 页把 key 存入 tab 级的
sessionStorage; - bootstrap 页用
location.replace('/')跳转到/。
之后,可见的屏幕 URL 就是干净的 /,而不再是 /?key=...。相应地:带有效 cookie 的 GET / 返回当前屏幕;不带有效 cookie 的 GET / 仍然返回友好的 403 页;GET /?key=<wrong> 返回 403。
为什么选 sessionStorage:helper 需要一个能扛过同端口重启、且不完全依赖 cookie 行为的重连凭据。由于屏幕 HTML 属于受信任的同源 UI,把 key 放在 tab 级存储里在这个威胁模型下是可接受的,并且显著优于把 key 留在地址栏、历史和 Referer 表面。
仓库中的实现与该设计一一对应。server.cjs 中的 bootstrapPage 生成的正是文档描述的页面:先 sessionStorage.setItem('brainstorm-session-key', <key>),再 location.replace('/');HTTP 处理分支在 handleRequest 中区分两种情况——路径为 / 且 query 中 key 有效时返回 bootstrap 页并 Set-Cookie,否则(凭 cookie)返回注入过 helper 的屏幕 HTML。cookie 的设置携带 HttpOnly; SameSite=Strict; Path=/,让同源子资源(/files/*、WebSocket)可以免费继承凭据,同时把 cookie 对页面脚本不可见。
这里有一个值得注意的实现细节:cookie 名以实际绑定端口为准(brainstorm-key-<port>,见 onListen)。由于多个 companion 会话共享 localhost cookie 罐,以端口命名可以防止与另一个服务器实例的 cookie 撞名——这正是“cookie 行为本身不可全信”这一判断在实现层的呼应。
设计二:WebSocket 同源强制——cookie 之外再加 Origin 校验
设计文档要求 WebSocket 升级必须同时通过两项检查:
- 有效的会话认证(query key 或 cookie);
- 如果请求带
Origin头,它必须等于请求目标源。
比较规则为:
Origin === "http://" + req.headers.host
浏览器攻击者页面的请求长这样:
Origin: http://localhost:9999
Host: localhost:58088
即使浏览器带上了 companion 的 cookie,也必须拒绝。而合法的 companion 页面:
Origin: http://localhost:58088
Host: localhost:58088
在 key 或 cookie 有效时应当接受。非浏览器的直接客户端可以不带 Origin,但仍然需要会话 key。
源码实现集中在两个函数。isAllowedWebSocketOrigin 精确落实了上述比较逻辑:无 Origin 头则放行(返回 true),有 Origin 但无 host 则拒绝,否则要求 origin === 'http://' + host;而 handleUpgrade 把两道闸门串起来——!isAuthorized(req) || !isAllowedWebSocketOrigin(req) 时直接 socket.destroy()。isAuthorized(同文件 L341-L353)对 query key 和 cookie 都使用恒定时间比较(crypto.timingSafeEqual),避免时序侧信道。
回归测试把这条边界变成了可执行证据。auth.test.js 中有一组针对性用例:
- “WS upgrade with valid cookie and same-origin Origin opens”——带有效 cookie 与同源
Origin的升级可以建立; - “WS upgrade with valid cookie but cross-origin Origin is rejected”——模拟攻击者:携带有效 cookie、
Origin: http://localhost:9999发起升级,若意外建立则立即发送{ choice: 'attacker-injected' }事件;断言结果是连接被拒,且state/events文件不存在(auth.test.js L271-L287)。这与设计文档验收标准中“之前能写入attacker-injected的安全探针现在无法打开 WebSocket,state/events保持不变”完全对应; - 另有“WS upgrade without key is rejected”“WS upgrade with valid key opens”覆盖无
Origin的直接客户端场景。
设计三:Helper 重连凭据——sessionStorage 里的 key
设计文档要求 helper 从 sessionStorage 读取 tab 级 key,并把它附加到 WebSocket URL 上:
ws://<host>/?key=<stored-key>
如果没有存储的 key,helper 回退到现有的仅 cookie ws://<host> 行为,为“已加载页面有有效 cookie 但无 storage 条目”的情况保留兼容。
helper.js 的实现对应该设计:sessionKey() 读取 brainstorm-session-key;websocketUrl() 在有 key 时构造 ws://<host>/?key=<key>,无 key 时退回 ws://<host>。该 helper 由服务器在每次屏幕响应时注入(helperInjection),因此它同时服务于连接状态展示(status pill)与断线重连(500ms 起步、指数退避至 30s 的 reconnectDelay,15s 未恢复则显示 tombstone)。
还有一个设计中没有逐字展开、但直接服务于“重启重连”验收标准的实现:reloadAfterRecovery()。当页面从 tombstone 状态恢复连接(典型场景就是同端口重启)时,helper 会走 /?key=<stored-key> 重新执行一次 bootstrap,先把 cookie 刷新掉,再让可见 URL 回到干净的 /。这一步解决了缺陷 4 中“重启后 cookie 不再被出示、tab 卡在 tombstone”的根源:重连不再依赖“浏览器恰好还会送 cookie”,而是主动用存储的 key 重新认证。
lifecycle.test.js 提供了对应的自动化证据:
- “persists the bound port AND key, and restores both on restart”——两次进程启动使用相同的
.last-port与.last-token文件,断言重启后端口与 key 均保持不变(L217-L244); - “stored key can authenticate WebSocket after same-port restart”——先起服务器 A 拿到 key,杀掉后以相同持久文件起服务器 B,然后用 A 的 key 在同端口发起带同源
Origin的 WebSocket,断言连接建立(L281-L321)。这正是设计文档测试策略中“same-port/same-token restart can authenticate reconnect with the stored key”一条的落地。
设计四:/files/* 目录收敛——realpath 边界
设计要求文件服务器继续拒绝空文件名和 dotfile,并额外保证文件是 CONTENT_DIR 之内的真实常规文件。边界用 realpath 收敛判定:
- 计算
realContentDir = fs.realpathSync(CONTENT_DIR); - 计算
realFilePath = fs.realpathSync(filePath); - 仅当
realFilePath是realContentDir的子孙时提供; - 符号链接以及内容目录之外的任何目标一律 404。
服务器继续使用 path.basename,嵌套路径保持不支持。
isRegularFileInsideContentDir 是这一设计的实现,且比文档描述还多了一层防护:先 lstatSync,遇到符号链接直接拒绝,stat.nlink !== 1 也拒绝(挡住硬链接——同一 inode 可能位于 state/ 下),最后再做 realpath 前缀比较 realFilePath.startsWith(realContentDir + path.sep)。/files/* 请求路径在 handleRequest 中先取 path.basename,再交给该函数判定;同一函数还被屏幕选择逻辑 getNewestScreen 复用——即“通过符号链接把 state/server-info 伪装成屏幕文件”这条旁路也被封死。
测试在 server.test.js 中构造了完整的逃逸探针:在 content/ 下创建指向 state/server-info 的符号链接,请求 /files/linked-server-info.txt 必须 404 且响应体不含 server-started(L261-L270);随后还有硬链接版本、以及经根屏幕选择(把 server-info 伪装成 *.html 屏幕)的符号链接/硬链接用例(L274-L320),逐一断言 server-info 内容("type":"server-started"、"state_dir")不会出现在任何响应中。
设计五:泄密削减响应头——保守但克制
设计要求添加一组“不阻塞内联脚本与未来同源 vendored 库”的保守响应头:
Referrer-Policy: no-referrer
Cache-Control: no-store
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'
Cross-Origin-Resource-Policy: same-origin
并明确本轮不加限制性 script-src CSP:companion 当前会注入内联 helper 脚本,未来屏幕可能加载同源 vendored 库——这正是威胁模型中“future same-origin vendored UI libraries still work”那条验收项的落地方式。
实现是统一的 securityHeaders():五个头作为默认值,允许调用方叠加。它被应用到所有响应路径——未认证的 403 页(L388-L391)、bootstrap 页、屏幕页、/files/* 文件与兜底 404,保证 key 不会经 Referer、缓存或 iframe 嵌套外泄。
测试侧的 EXPECTED_SECURITY_HEADERS 表(auth.test.js L28-L34)逐项断言五个头的值,并由 assertSecurityHeaders 分别施加到 403 响应、HTML 响应(keyed 加载)与 /files 文件响应上——对应设计文档测试策略中“security headers are present on normal HTML, bootstrap, 403, and file responses”一条。
设计六与七:gitignore 持久状态、测试稳定与 lint
持久会话状态进 .gitignore。 当使用 --project-dir 时,start-server.sh 会把会话目录放在 <project-dir>/.superpowers/brainstorm/<session-id> 下,并在 .last-port / .last-token 两个持久文件 中保存绑定端口与 token(脚本以 umask 077 运行,服务器对 token 文件执行 0600 权限收敛)。设计文档要求把 .superpowers/ 加入仓库根 .gitignore,避免这些含 key 的持久状态被 git add . 误提交——当前仓库根 .gitignore 第 4 行 即为 .superpowers/,与该设计一致。
shell lint 与生命周期测试清理。 设计要求清理所触达的 start/stop 脚本的 shell lint 警告(lint 工具见 scripts/lint-shell.sh),并更新那个调用 start-server.sh --idle-timeout-minutes 的生命周期测试:在 Codex 的 CODEX_CI 前台自动检测(start-server.sh L97-L100 会把脚本切到前台模式)下它可能挂死,因此测试在期望脚本返回启动 JSON 时必须强制 --background。当前 lifecycle.test.js 中对应用例 正是这么写的:非 Windows-like 环境走 execFileSync('bash', [START, '--project-dir', dir, '--idle-timeout-minutes', '5', '--background']),断言 idle_timeout_ms === 300000(5 分钟换算为毫秒)。
测试策略与验收标准
设计文档规定所有行为变更走 TDD:先写失败的聚焦测试 → 运行并确认它以预期原因失败 → 实现最小修复 → 重跑聚焦测试 → 重跑完整的 brainstorm-server 套件。完整的测试套件入口见 tests/brainstorm-server/package.json:npm test 依次运行 ws-protocol、helper、browser-launcher、auth、branding、server、lifecycle 七个 Node 测试与 start/stop 两个 shell 测试(ws 包是唯一的 test-only 依赖,运行时零依赖的约束未被破坏)。
文档列出的必需聚焦回归与仓库中测试的对应关系是:
| 设计要求的聚焦回归 | 仓库中的测试证据 |
|---|---|
有效 keyed / 返回 bootstrap 而非屏幕内容 |
auth.test.js “GET / with valid query returns bootstrap instead of screen content” |
bootstrap 把 key 存入 sessionStorage 并剥离 URL |
auth.test.js bootstrap 脚本执行用例(含 storage 写入失败仍执行 location.replace('/') 的健壮性分支) |
仅 cookie 的 / 仍提供屏幕内容 |
auth.test.js “GET / with valid cookie (no query key) serves the screen” |
helper 用 sessionStorage key 构造 WebSocket URL |
helper.test.js(导出 sessionKey/websocketUrl 相关纯逻辑)与 helper.js 实现 |
| 同源 cookie WebSocket 可建立 | auth.test.js “valid cookie and same-origin Origin opens” |
| 跨源 cookie WebSocket 被拒且不写事件 | auth.test.js cross-origin 探针用例(state/events 不存在) |
无 Origin 的直接 key WebSocket 仍可建立 |
auth.test.js “WS upgrade with valid key opens” |
指向 state/server-info 的符号链接返回 404 |
server.test.js symlink/hardlink 逃逸用例 |
| 安全头覆盖 HTML/bootstrap/403/file 响应 | auth.test.js assertSecurityHeaders 系列 |
| 同端口/同 token 重启可用存储 key 认证重连 | lifecycle.test.js “stored key can authenticate WebSocket after same-port restart” |
| shell lint 通过;Codex 下生命周期套件不挂死 | scripts/lint-shell.sh 与 lifecycle.test.js 的 --background 强制 |
验收标准同样具体:cd tests/brainstorm-server && npm test 可重复通过且不挂死;跨源 attacker-injected 探针无法建立连接且 state/events 不变;server-info 符号链接探针返回 404;有头或无头浏览器的 keyed 加载最终停在干净的 / URL 且状态 pill 到达 Connected;同端口/同 token 重启无需手动刷新即可自动重连;所触达 shell 脚本通过 scripts/lint-shell.sh。
延后工作:沙箱 iframe 架构
设计文档最后显式声明:如果未来需要把屏幕 HTML 当作不可信内容对待,应当单独设计一套沙箱 iframe 架构——把生成的屏幕隔离在独立源或沙箱框架中,仅通过窄 postMessage 桥暴露用户选择——并明确不要把它并入本次修复。这条延后项与威胁模型中的范围外边界首尾呼应,也给出了这个方案的可演进方向:当前的七项加固把“认证边界”做严了,而“内容边界”(屏幕 HTML 本身的信任问题)留给了未来的独立架构决策。
小结
这篇设计文档展示了小服务器场景下认证加固的典型打法:用 Bootstrap 页把一次性 key 从 URL 面迁到 tab 级存储与 HttpOnly cookie;用“认证 + Origin 双重闸门”封死跨源 tab 借 cookie 注入 state/events 的路径;用 sessionStorage key 让同端口重启重连不再依赖浏览器 cookie 的不可控行为;用 lstat/硬链接/realpath 三重判定把 /files/* 收敛到内容目录内;再用一组刻意保守的响应头削减 Referer、缓存与嵌套泄漏面——全程零运行时依赖,全部行为以可重复运行的测试固化。对任何需要把“本机生成 UI + 会话内双向通道”暴露给本地浏览器的项目,这套“文档定威胁模型 → 每条缺口配一个聚焦回归测试 → 实现与测试一一对应”的结构都值得参照。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00