Superpowers 视觉伴侣认证加固:Bootstrap Key 加载、WebSocket 同源校验与 /files 目录逃逸防护设计解析
【免费下载链接】superpowersAn agentic skills framework & software development methodology that works.项目地址: https://gitcode.com/GitHub_Trending/su/superpowers
本文以 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-srcCSP。
现状缺陷:五项已复现的失败
设计文档基于自动化测试与有头浏览器测试,在修复分支上确认了五个具体失败:
- 跨源 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 回退到现有的仅 cookiews://<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-srcCSP: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 用sessionStoragekey 构造 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.jsassertSecurityHeaders系列 |
| 同端口/同 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的路径;用sessionStoragekey 让同端口重启重连不再依赖浏览器 cookie 的不可控行为;用 lstat/硬链接/realpath 三重判定把/files/*收敛到内容目录内;再用一组刻意保守的响应头削减 Referer、缓存与嵌套泄漏面——全程零运行时依赖,全部行为以可重复运行的测试固化。对任何需要把“本机生成 UI + 会话内双向通道”暴露给本地浏览器的项目,这套“文档定威胁模型 → 每条缺口配一个聚焦回归测试 → 实现与测试一一对应”的结构都值得参照。
【免费下载链接】superpowersAn agentic skills framework & software development methodology that works.项目地址: https://gitcode.com/GitHub_Trending/su/superpowers
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考