首页
/ Superpowers Visual Companion 认证加固:从密钥 Bootstrap、同源 WebSocket 到 realpath 目录包含的完整实现

Superpowers Visual Companion 认证加固:从密钥 Bootstrap、同源 WebSocket 到 realpath 目录包含的完整实现

2026-09-06 21:41:06作者:卓炯娓

Superpowers 的 brainstorming 视觉伴侣(Visual Companion)是一个由 Node.js 内置模块实现的零依赖本地 HTTP + WebSocket 服务,用于在头脑风暴会话中向浏览器推送可交互屏幕并回传用户选择。本文基于仓库中的实施计划 2026-06-10-visual-companion-auth-hardening.md 展开,完整讲解这次认证加固的威胁模型、十个 TDD 任务(带密钥的根路径 Bootstrap、WebSocket 同源校验、sessionStorage 重连凭证、安全响应头、/files/* realpath 包含、重启重连回归、Shell lint 与 .gitignore 治理、全量自动化验证与安全探针复测),并结合 server.cjshelper.js 的实际源码印证每项机制的落地方式,适合需要为本地 Agent UI 设计"密钥 + Cookie + 同源"三层认证体系的开发者参考。

加固目标、架构与技术栈

该计划开篇明确了三项约束,也是全文的判断标准:

  • 目标(Goal):在不破坏"可信同源屏幕 JavaScript"和未来同源内嵌(vendored)UI 库的前提下,加固 brainstorming 视觉伴侣的认证与重连流程;
  • 架构(Architecture):带密钥的根路径加载变为一个 Bootstrap 步骤——先设置 Cookie、把密钥存入标签页作用域的 sessionStorage,再导航到裸的 / 屏幕地址;WebSocket 要求"有效认证 + 浏览器同源 Origin"双重通过,/files/* 用 realpath 包含(realpath containment)阻止内容目录逃逸;
  • 技术栈(Tech Stack):Node.js 内置模块(httpfspathcrypto)、零运行时依赖、仅测试使用的 ws 依赖、Bash 启停脚本、仓库自带的 Shell lint 脚本。

计划还包含一条仓库级纪律:执行期间除非明确要求,不得提交——仓库自身的指令覆盖通用计划模板的提交节奏。

配套的设计文档 2026-06-10-visual-companion-auth-hardening-design.md 给出了完整威胁模型,值得先交代清楚,因为它决定了每项修复的边界:

  • 受保护资产:伴侣提供的屏幕内容、会话密钥(session key)、Agent 将其作为用户反馈读取的 state/events 文件、伴侣会话目录下的本地文件;
  • 范围内攻击者:另一个 localhost 端口上的恶意浏览器标签页;能向伴侣发请求但不应以伴侣 UI 身份认证的浏览器页面;服务器绑定非回环地址时的直连远程客户端;经由 URL 历史、Referrer 或误提交的本地状态造成的意外泄露;指向内容目录之外符号链接或路径技巧;
  • 范围外(有意为之):恶意的 Agent 生成的屏幕 HTML、恶意的同源内嵌 JavaScript。设计文档明确指出,屏幕属于 Agent 的 UI 表面,今天可以使用内联脚本,未来可能使用 Alpine、Three.js 这类同源库;若要防御恶意屏幕 HTML 需要更重的沙箱 iframe 架构,不属于本次加固范围。

设计文档同时列出了 PR 分支上被自动化与有头浏览器测试发现的五个现有失败:跨源 localhost 页面可以借用 Cookie 打开 WebSocket 并向 state/events 写入攻击者选择;/files/* 会服务内容目录外的符号链接(包括指向 state/server-info、内含带密钥 URL 的链接);会话密钥残留在屏幕页面可见 URL 中;helper 用不带密钥的 ws://host 重连,同端口重启后浏览器不再向重启的服务器出示 Cookie,导致已打开的标签页卡在 tombstone("Companion paused"覆盖层)直到手动刷新;Shell lint 与生命周期测试需要清理以保证在 Codex 环境下稳定。

文件地图:一次加固改动了什么

计划的 File Map 列出了全部改动面,后续十个任务都落在这张地图内:

文件 改动内容
skills/brainstorming/scripts/server.cjs 增加 Bootstrap 响应、共享安全响应头、WebSocket Origin 校验、/files/* realpath 包含
skills/brainstorming/scripts/helper.js 读取存储的会话密钥并拼接到 WebSocket URL
tests/brainstorm-server/auth.test.js Bootstrap、响应头、同源 WS、跨源 WS、Cookie/文件认证回归
tests/brainstorm-server/helper.test.js 基于模拟浏览器的 sessionStorage WS URL 覆盖
tests/brainstorm-server/server.test.js /files/* 符号链接包含回归
tests/brainstorm-server/lifecycle.test.js 让 start-server 超时参数测试强制后台模式;补充重启重连凭证覆盖
skills/brainstorming/scripts/start-server.shstop-server.sh 修复 Shell lint
仓库根 .gitignore 新增 .superpowers/
skills/brainstorming/visual-companion.md(可选) 若行为变化需要面向操作者说明,则补充 Bootstrap URL 剥离与可信同源屏幕 JS 的说明

任务 1:带密钥的根路径加载(Bootstrap)

这是整个加固的核心:GET /?key=<token> 不再是屏幕响应,而是一个 Bootstrap 响应。计划要求在 auth.test.js 中先加两个 RED 测试:带有效查询密钥的 GET / 应返回包含 sessionStoragelocation.replace 的 Bootstrap 页,而不是屏幕 HTML;带有效 Cookie 的裸 GET / 则照常返回屏幕内容。

计划的 RED 测试断言骨架(现仓库中该文件保留了同名测试,且实际实现比计划多了一层防御,见下文"实现与计划的差异"一节):

await test('GET / with valid query returns bootstrap instead of screen content', async () => {
  const res = await get('/', { key: TOKEN });
  assert.strictEqual(res.status, 200);
  assert(res.body.includes('sessionStorage'), 'bootstrap should store the session key in tab storage');
  assert(res.body.includes('location.replace'), 'bootstrap should navigate to the bare root URL');
  assert(!res.body.includes('Secret screen'), 'bootstrap must not serve screen HTML at the keyed URL');
});

await test('GET / with valid cookie serves the screen after bootstrap', async () => {
  const res = await get('/', { cookie: `${COOKIE_NAME}=${TOKEN}` });
  assert.strictEqual(res.status, 200);
  assert(res.body.includes('Secret screen'), 'cookie-authenticated bare root should serve the screen');
  assert(!res.body.includes('sessionStorage'), 'bare screen response should not be the bootstrap page');
});

随后在 server.cjs 中实现最小 Bootstrap 响应。计划给出的实现(与实际合入的 bootstrapPage 几乎一致,实际版本给 sessionStorage.setItem 包了 try/catch,保证存储被浏览器拦截时仍然跳转):

function bootstrapPage(key) {
  const jsonKey = JSON.stringify(String(key));
  return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Opening Brainstorm Companion</title></head>
<body>
<script>
sessionStorage.setItem('brainstorm-session-key', ${jsonKey});
location.replace('/');
</script>
</body>
</html>`;
}

配合一个从 URL 提取查询密钥的小工具,并在 handleRequest 中于"通过认证、设置 Cookie 之后、返回屏幕 HTML 之前"拦截根路径上的有效查询密钥:

function queryKey(url) {
  const q = url.indexOf('?');
  if (q < 0) return null;
  return new URLSearchParams(url.slice(q + 1)).get('key');
}

// handleRequest 内:
const pathname = pathnameOf(req.url);
const keyFromQuery = queryKey(req.url);
if (req.method === 'GET' && pathname === '/' && keyFromQuery && timingSafeEqualStr(keyFromQuery, TOKEN)) {
  res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
  res.end(bootstrapPage(keyFromQuery));
  return;
}

实际实现见 server.cjs 的 handleRequest:密钥比较使用 timingSafeEqualStr(内部为 crypto.timingSafeEqual,长度不等直接返回 false,防止时序侧信道),随后走 Bootstrap 分支。计划还特意标注:若先实现任务 1、securityHeaders(任务 4)尚未引入,可临时用 res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }),待任务 4 再替换——这体现了计划中任务间依赖的显式管理。

为什么要用 sessionStorage 而不是别的机制?设计文档的解释是:helper 需要一个能扛过同端口重启、且不单纯依赖浏览器 Cookie 行为的"重连凭证";由于屏幕 HTML 本身就是可信的同源 UI,把密钥存入标签页作用域存储在当前威胁模型下可以接受,而且它实质上好过把密钥留在地址栏、历史与 Referrer 面上。sessionStorage 的标签页作用域还意味着密钥不会跨标签页扩散。

任务 2:WebSocket Origin 强制校验

这一步堵住的是计划威胁模型里最危险的攻击:真实伴侣页面设置 Cookie 之后,另一个 localhost 端口上的恶意标签页可以用同样的 Cookie 打开 WebSocket,把 attacker-injected 之类的选择写进 state/events,等同于对在线 Agent 会话做提示注入。

计划在 auth.test.js 中把 wsConnect 扩展为支持 origin 选项(现仓库保留的实现见 auth.test.js#L59-L74),并新增两条测试:

await test('WS upgrade with valid cookie and same-origin Origin opens', async () => {
  const { outcome, ws } = await wsConnect({
    cookie: `${COOKIE_NAME}=${TOKEN}`,
    origin: `http://localhost:${TEST_PORT}`
  });
  ws.close();
  assert.strictEqual(outcome, 'opened');
});

await test('WS upgrade with valid cookie but cross-origin Origin is rejected', async () => {
  const eventsFile = path.join(TEST_DIR, 'state', 'events');
  if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);

  const { outcome, ws } = await wsConnect({
    cookie: `${COOKIE_NAME}=${TOKEN}`,
    origin: 'http://localhost:9999'
  });
  if (outcome === 'opened') {
    ws.send(JSON.stringify({ type: 'choice', choice: 'attacker-injected', text: 'local attacker probe' }));
    await sleep(300);
  }
  ws.close();

  assert.strictEqual(outcome, 'rejected', 'cross-origin browser WS must not open even with cookie');
  assert(!fs.existsSync(eventsFile), 'cross-origin WS must not write state/events');
});

第二条测试值得细读:它先删掉 state/events,再假设连接真的被打开并尝试注入事件,最后断言"连接被拒绝 state/events 不存在"——即使攻击者绕过断言路径,也不会留下写入证据。这是"攻击载荷 + 无副作用"双重断言的写法。

服务端实现在 server.cjs

function isAllowedWebSocketOrigin(req) {
  const origin = req.headers.origin;
  if (!origin) return true; // 非浏览器客户端仍然必须携带会话密钥
  const host = req.headers.host;
  if (!host) return false;
  return origin === 'http://' + host;
}

规则是:

  • 请求携带 Origin(浏览器必带)时,必须严格等于 "http://" + Host。攻击者页面形如 Origin: http://localhost:9999 / Host: localhost:58088 的组合必然被拒;合法页面 Origin: http://localhost:58088 / Host: localhost:58088 在密钥或 Cookie 有效时通过;
  • Origin 的直接客户端(脚本、测试用的 ws 库)仍然允许,但必须持有会话密钥。设计文档特别强调密钥是统一防线:无论回环绑定、隧道还是远程绑定,Host/Origin 白名单都挡不住 DNS rebinding,而密钥可以。

handleUpgrade 中的校验即计划要求的那一行:

function handleUpgrade(req, socket) {
  if (!isAuthorized(req) || !isAllowedWebSocketOrigin(req)) { socket.destroy(); return; }
  // ... 校验 Sec-WebSocket-Key 并执行 RFC 6455 握手
}

注意这里直接 socket.destroy() 而不是回复 HTTP 错误码——对升级请求,直接销毁连接是最简且不留握手痕迹的做法。认证侧的 isAuthorizedserver.cjs#L341-L353)则实现了"查询密钥优先、否则回退 Cookie"的逻辑,且显式错误密钥不会回退到 Cookie——auth.test.js#L177-L180 专门断言了"错误 key + 有效 Cookie" 仍返回 403,防止攻击者用一个假 key 探测出 Cookie 仍然有效。

任务 3:helper 用存储密钥重连

浏览器端的 helper.js 是注入到每个屏幕末尾的客户端脚本,负责 WebSocket 连接、断线指数退避重连、状态胶囊(Connecting / Connected / Reconnecting / Disconnected)与 tombstone 覆盖层。加固前它连接的是不带密钥的 ws://<host>,完全依赖浏览器 Cookie——这正是同端口重启后标签页卡死在 tombstone 的根因。

计划在 helper.test.js 中基于"模拟浏览器"先写 RED 测试。该测试文件构造了一个 makeEnv() 沙箱(见 helper.test.js#L87-L132):假的 window.sessionStorage、假的 WebSocket(记录 URL 到 sockets 数组)、假的定时器与可推进的时钟,然后用 new Function(...Object.keys(env), src)(...Object.values(env)) 在受控环境里执行 helper 源码,真正驱动重连状态机而不是 grep 源码。计划要求的两条 URL 测试:

test('uses sessionStorage key in the WebSocket URL when present', () => {
  const e = makeEnv();
  e.state.sessionKey = 'stored-key-abc';
  e.boot();
  assert.strictEqual(e.sockets[0].url, 'ws://localhost:7777/?key=stored-key-abc');
});

test('uses cookie-only WebSocket URL when no sessionStorage key is present', () => {
  const e = makeEnv();
  e.state.sessionKey = null;
  e.boot();
  assert.strictEqual(e.sockets[0].url, 'ws://localhost:7777');
});

第二条是兼容性回退:已经加载、只有 Cookie 而没有存储条目的旧页面仍然走原来的 ws://<host> 行为。生产实现即 helper.js#L25-L35

function sessionKey() {
  try {
    return window.sessionStorage && window.sessionStorage.getItem('brainstorm-session-key');
  } catch (e) {}
  return null;
}

function websocketUrl() {
  const key = sessionKey();
  return 'ws://' + window.location.host + (key ? '/?key=' + encodeURIComponent(key) : '');
}

helper.js#L77-L95connect()new WebSocket(websocketUrl()) 建连;另有 reloadAfterRecovery() 在 tombstone 状态下恢复连接时,若存在存储密钥则 location.replace('/?key=' + ...) 走一遍 Bootstrap 以刷新 Cookie,否则退回 location.reload()——这条恢复路径也被 helper.test.js#L174-L194 的模拟浏览器测试覆盖(带密钥时断言发生的是 replace('/?key=stored-key-abc') 而非裸 reload)。

任务 4:共享安全响应头

计划要求给所有 HTML / 文件 / 403 / 404 响应统一加一组"降低泄露 + 防框架化"的保守响应头,并且刻意不加 script-src CSP——因为伴侣现在注入内联 helper 脚本,未来屏幕还可能加载同源内嵌库,过严的 CSP 会破坏可信屏幕这一前提。

RED 测试断言(现仓库以 assertSecurityHeaders 辅助函数统一复用,见 auth.test.js#L28-L34L82-L86):

await test('HTML responses include leak-reduction and anti-framing headers', async () => {
  const res = await get('/', { key: TOKEN });
  assert.strictEqual(res.headers['referrer-policy'], 'no-referrer');
  assert.strictEqual(res.headers['cache-control'], 'no-store');
  assert.strictEqual(res.headers['x-frame-options'], 'DENY');
  assert.strictEqual(res.headers['content-security-policy'], "frame-ancestors 'none'");
  assert.strictEqual(res.headers['cross-origin-resource-policy'], 'same-origin');
});

403 响应同样必须带这套头。服务端实现是 server.cjs 的 securityHeaders

function securityHeaders(headers = {}) {
  return {
    'Referrer-Policy': 'no-referrer',
    'Cache-Control': 'no-store',
    'X-Frame-Options': 'DENY',
    'Content-Security-Policy': "frame-ancestors 'none'",
    'Cross-Origin-Resource-Policy': 'same-origin',
    ...headers
  };
}

...headers 展开在末尾,允许调用方附加 Content-Type 而不覆盖安全头。各响应的接入点(handleRequest):403 为 res.writeHead(403, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' })),Bootstrap 与屏幕 HTML 用 200 + 同名头,/files/*securityHeaders({ 'Content-Type': contentType }),其余 404 用 securityHeaders()。各头的语义:no-referrer 确保从屏幕页面发出的任何外发请求不带 Referer(密钥不会再经 Referer 面外泄);no-store 阻止屏幕内容进缓存;X-Frame-Options: DENYframe-ancestors 'none' 双保险防点击劫持;Cross-Origin-Resource-Policy: same-origin 防止本地其他源的页面把它当资源跨源抓取。

另外,auth.test.js#L208-L214 还断言了有效密钥加载设置的 Cookie 为 HttpOnly + SameSite=Strict,对应 server.cjs#L398-L399Set-Cookie 逻辑;且 Cookie 名按实际绑定端口生成(brainstorm-key-<port>,见 onListen),避免 EADDRINUSE 回退换端口后与另一个会话在共享的 localhost Cookie 罐里撞名。

任务 5:/files/* 的 realpath 包含

背景:服务器在 state/server-info 里写入带密钥 URL 的启动信息(权限 0600),而 /files/* 曾直接 readFileSync 拼出来的路径。content/ 目录里若存在一个指向 state/server-info 的符号链接,攻击者即可借合法认证通道读出密钥。

RED 测试(现仓库 server.test.js#L261-L269 保留了同名回归):

await test('does not serve symlinks that escape content dir via /files/', async () => {
  const target = path.join(STATE_DIR, 'server-info');
  const link = path.join(CONTENT_DIR, 'linked-server-info.txt');
  try { fs.unlinkSync(link); } catch (e) {}
  fs.symlinkSync(target, link);

  const res = await fetch(`http://localhost:${TEST_PORT}/files/linked-server-info.txt`);
  assert.strictEqual(res.status, 404, 'symlink to state/server-info must not be served');
  assert(!res.body.includes('server-started'), 'response must not include server-info body');
});

包含判定逻辑(计划版本 + 实际实现 isRegularFileInsideContentDir):

function isRegularFileInsideContentDir(filePath) {
  let stat, realContentDir, realFilePath;
  try {
    stat = fs.lstatSync(filePath);
    if (stat.isSymbolicLink()) return false;
    if (!stat.isFile()) return false;
    if (stat.nlink !== 1) return false;   // 实际实现额外增加的硬链接检查
    realContentDir = fs.realpathSync(CONTENT_DIR);
    realFilePath = fs.realpathSync(filePath);
  } catch (e) {
    return false;
  }
  return realFilePath.startsWith(realContentDir + path.sep);
}

要点:lstat 先于 realpath——符号链接本身即被拒,不存在"解析后被放行"的窗口;realpath 前缀比较用 realContentDir + path.sep 结尾,避免 /content-evil/content 前缀误匹配;任何 stat/realpath 异常一律返回 false(fail closed)。/files/* 的最终守卫(server.cjs#L420-L433)保持 path.basename 取文件名——嵌套路径依然不支持——并拒绝空名与点文件:

if (!fileName || fileName.startsWith('.') || !isRegularFileInsideContentDir(filePath)) {
  res.writeHead(404, securityHeaders());
  res.end('Not found');
  return;
}

实现与计划的差异:合入的实现比计划多了一条 stat.nlink !== 1 检查,把硬链接也判为拒绝——对应的回归测试 does not serve hard links to files outside content dir via /files/server.test.js#L273-L283。同一函数还被 getNewestScreen 复用:根路径选择"最新屏幕"时同样跳过逃逸链接,server.test.js#L284-L300 验证了根屏幕渲染不会被符号链接带出 state/server-info。这是把一处包含函数变成两个端点共享的安全边界,属于计划 File Map 之外的合理增量。

任务 6:重启重连回归(同端口 + 同密钥)

这条回归验证端到端场景:服务器杀掉后同目录重启、复用原端口,用旧实例启动时的密钥连接新实例的 WebSocket 必须能打开——这正是浏览器标签页"存了密钥、服务重启了"时的真实路径。

测试逻辑(lifecycle.test.js#L281 起):

  1. fs.mkdtempSync('/tmp/bs-reconnect-') 临时目录中分别指定 BRAINSTORM_PORT_FILE / BRAINSTORM_TOKEN_FILE,并设置 BRAINSTORM_LIFECYCLE_CHECK_MS=100000 拉长看门狗;
  2. BRAINSTORM_DIR=<dir>/s1 启动实例 A,从 stdout 的 server-started JSON 中取出带 ?key= 的 URL,解析出 keyA,杀掉 A;
  3. BRAINSTORM_DIR=<dir>/s2 启动实例 B,断言 infoB.port === infoA.port(重启复用端口);
  4. ws://localhost:<port>/?key=<keyA> 并显式带上同源 Origin 头发起 WebSocket 升级,断言打开成功;
  5. finally 中关连接、杀进程、fs.rmSync 清理临时目录。

计划特意注明:该测试在任务 2、3 落地后可能一开始就通过,若属此种情况保留它作为覆盖但不称其为 RED——真实浏览器重连行为主要由任务 3 与最终人工/无头浏览器验证兜底。这个措辞体现了对"测试先行"与"集成覆盖"的区分。

端口与密钥的持久化机制值得展开,它解释了"重启为什么能认出旧密钥":server.cjs#L85-L151 中,preferredPort() 依次尝试 BRAINSTORM_PORT 环境变量、PORT_FILE 记录的上次端口(校验整数且落在 1023~65535)、随机高端口(49152~65534);initialToken() 依次尝试 BRAINSTORM_TOKEN 环境变量、TOKEN_FILE 中的既有 token(须匹配 /^[0-9a-f]{32,}$/i,读取后顺手 chmod 0600)、否则 crypto.randomBytes(32) 生成。onListen 只在"拿到了首选端口"时把端口与 token 写回文件——若因端口占用回退到随机端口,就不写回,避免覆盖共享文件、把另一个会话的已开标签页弄丢。密钥文件与 server-info 都以 owner-only 权限落盘。

任务 7:生命周期挂起修复与 Shell lint

两个工程性修复,保证 npm test 在 CI(Codex 环境)下不挂起、lint 干净:

  1. Shell lint:计划先复现失败(运行 scripts/lint-shell.sh 检查三个脚本),预期输出:

    SC2164: skills/brainstorming/scripts/start-server.sh line 128: cd "$SCRIPT_DIR"
    SC2034: skills/brainstorming/scripts/start-server.sh line 166: for i in {1..50}
    SC2034: skills/brainstorming/scripts/stop-server.sh line 57: for i in {1..20}
    

    最小修复:cd "$SCRIPT_DIR" 改为 cd "$SCRIPT_DIR" || exit 1(SC2164:cd 失败要显式处理);两处未被读取的循环变量 for i in {1..50} / for i in {1..20} 改为 for _ in ...(SC2034:未用变量)。

  2. 生命周期挂起lifecycle.test.jsstart-server.sh --idle-timeout-minutes 的测试原本不传 --background,而 Codex 的 CODEX_CI 会让 start-server.sh 进入前台模式导致 execFileSync 一直等待。修复是让测试命令强制后台模式:

    const out = execFileSync('bash', [START, '--project-dir', dir, '--idle-timeout-minutes', '5', '--background'], { encoding: 'utf8' });
    

    验证命令为 lint 退出 0 且 node lifecycle.test.js 无挂起地退出 0。

任务 8:用 .gitignore 收纳持久化的伴侣状态

使用 --project-dir 时,伴侣会把状态写到项目下的 .superpowers/ 目录(含 .last-token 等持久化文件)。计划用 git check-ignore 做了 RED→GREEN 的最小闭环:

# RED:确认当前无匹配规则(|| true 吞掉非零退出码)
git check-ignore .superpowers/brainstorm/.last-token || true
# 修复:在 .gitignore 追加
.superpowers/
# GREEN:应回显被忽略的路径
git check-ignore .superpowers/brainstorm/.last-token

预期 GREEN 输出即 .superpowers/brainstorm/.last-token。这一步防止含会话密钥的 .last-token 在真实项目目录里被顺手 git add 提交——密钥文件的安全(0600 + 忽略规则)在仓库内外各有一道。

任务 9:全量自动化验证

无任何代码改动,四层递进:

  1. 聚焦套件cd tests/brainstorm-server 后依次 node auth.test.jsnode helper.test.jsnode server.test.jsnode lifecycle.test.js,四条命令均须退出 0;
  2. 完整套件npm testtests/brainstorm-server/package.json 定义了测试入口),要求 ws-protocol、helper、auth、server、lifecycle、stop-server 全部通过;
  3. 重复三次以暴露生命周期/文件监听类的偶发抖动:for i in 1 2 3; do npm test || exit 1; done,三次通过且不挂起;
  4. Shell lintscripts/lint-shell.shstart-server.shstop-server.shtests/brainstorm-server/stop-server.test.sh 退出 0。

任务 10:安全探针复测与人工流程

自动化全绿后,复现此前发现问题的攻击探针。计划给出两条路径:有暂存探针则直接 node /tmp/.../probe-pr1720.cjs;没有则在 /tmp 重建最小探针——用固定 token 启动伴侣、无头 Chrome 加载带密钥 URL、在另一个 localhost 端口起攻击者页面、尝试 new WebSocket('ws://localhost:<companion-port>/') 并发送 {"type":"choice","choice":"attacker-injected"}、检查 state/events。修复后的预期矩阵:

探针 预期
无密钥 / 错误密钥的 HTTP 仍 403
同源 helper 页面 达到 Connected
跨源 WebSocket 打不开
state/events 不含 attacker-injected
指向 server-info 的符号链接 404
带密钥的浏览器加载 最终停在裸 /

最后跑一遍人工/浏览器流程:用 --project-dir --open 启动伴侣 → 推送一张屏幕 → 确认 URL 被剥离为 / → 状态胶囊到 Connected → 点击选项并核对 state/events → 停止并以同项目重启 → 确认已开标签页自动重连。预期全部步骤无需手动刷新 URL 通过。

自审清单:计划的收尾校验

计划末尾的 Self-Review Checklist 是这份实施文档的方法论浓缩,值得在采用同类流程时借鉴:

  • 规格覆盖:每条设计要求至少映射到一个任务(本计划的 10 个任务与 设计文档 的 7 个设计点一一对应,测试策略中的 12 条"必须回归"分散落在任务 1~7);
  • 占位符扫描:全文无未解决的占位标记或含糊的边缘情况步骤;
  • TDD 顺序:每个生产改动任务都从"聚焦失败测试或能展示当前失败的命令"开始(任务 6 是唯一例外,且计划显式说明了它可能是"出生即 GREEN"的集成覆盖);
  • 信任模型:保留可信同源屏幕 JavaScript 与未来同源内嵌库的能力(不加 script-src CSP、不加 iframe 沙箱——后者被明确列入 Deferred Work);
  • 不提交规则:执行过程不 commit。

小结:这套加固的可复用要点

server.cjs 的最终形态看,这次加固沉淀出一组本地 Agent UI 可直接借鉴的模式:

  1. 密钥是统一身份?key= 查询、Cookie、sessionStorage 三处承载同一个 32 字节随机 token,比较一律 timingSafeEqual,任何一处失效都能被其余两处补偿(密钥扛重启、Cookie 扛子资源、存储扛浏览器 Cookie 行为退化);
  2. Bootstrap 剥离 URL:带密钥 URL 只作为一次性入口,location.replace('/') 让可见地址、历史与 Referrer 面不再含密钥;
  3. 双重门禁而非单一白名单:WebSocket = 认证 同源 Origin;文件服务 = 认证 realpath 包含 + lstat/nlink 检查,认证绕过和路径绕过被解耦成两个独立防线;
  4. 保守头、严边界:只加不破坏内联脚本前提的防泄露/防框架化头,把"恶意屏幕 HTML"这类更大问题显式推到独立的沙箱架构议题;
  5. 测试即证据:每条安全性质(跨源注入、符号链接逃逸、重启重连、URL 剥离)都有对应自动化回归,且攻击者测试带"无副作用"断言。

读者若要在自己的仓库中审计类似机制,建议从 tests/brainstorm-server 目录读起:auth.test.js 是门禁矩阵、helper.test.js 是模拟浏览器驱动的重连状态机、server.test.js 是文件包含回归、lifecycle.test.js 是跨重启的集成证据,四个文件合起来就是一次完整的安全验收。

登录后查看全文
热门项目推荐
相关项目推荐