首页
/ superpowers Visual Companion 最终安全加固修复设计:根路由文件收容、回退令牌隔离与进程所有权证明

superpowers Visual Companion 最终安全加固修复设计:根路由文件收容、回退令牌隔离与进程所有权证明

2026-09-04 20:14:44作者:庞队千Virginia

本文围绕 superpowers 仓库中 Visual Companion(brainstorming 可视化伴侣服务)的"最终加固修复"设计文档展开,系统讲解该设计针对的五个遗留安全问题、每项修复的实现机制,以及配套的 TDD 回归测试策略。读完本文,你将理解如何为一个本地零依赖 Node.js 服务设计根路由的文件收容边界(防符号链接/硬链接逃逸)、端口回退场景下的令牌隔离与 fail-closed 策略,以及基于"每次启动实例 ID"的进程所有权证明来安全停止后台进程。

1. 背景与定位:一次只修问题、不扩面的收尾加固

本设计文档(2026-06-11-visual-companion-final-hardening-fixup-design.md)的目标是:完成 PR #1720 对 Visual Companion 的加固收尾,使分支以"干净的安全行为、确定性的测试、只包含 companion 工作的 PR diff"进入最终评审。它明确定位为一轮在既有认证加固(auth hardening)之上的 fixup,不应重新设计 companion 或扩大功能面。

上一轮加固已经交付了:keyed sessions(带密钥的会话)、同源 WebSocket 检查、URL key 剥离、/files/* 收容、泄减响应头(leak-reduction headers)、IPv6 URL 格式化、Windows 生命周期覆盖,以及 PR 证据更新。最终评审又发现了五个遗留问题:

  1. 根路由 GET / 的选屏路径仍可能提供 content/ 下指向目录外的符号链接或硬链接;
  2. 首选端口被占用时,回退服务器会复用持久化的 .last-token,造成同一项目下两个存活的 companion 服务器共用同一个 bearer key;
  3. 当强所有权证据不可用时,stop-server.sh 可能向无关的 node server.cjs 进程发信号;
  4. 部分测试可能静默通过"错误的回退进程"、在失败时泄漏后台进程,或在类似 Windows 的主机上假设符号链接可用;
  5. 由于分支包含一个已被单独处理的较旧 evals 子模块升级,PR 当前处于冲突状态。

非目标(Non-Goals)

设计同时划清了边界,明确本轮不做的事情:

  • 不加入 HTTPS 隧道或 wss:// origin 语义;
  • 不实现 opt-out、自由文本反馈或对比度辅助等 companion 功能;
  • 不 vendor Alpine、Three.js 或任何 JavaScript 库;
  • 不对恶意 agent 编写的 screen HTML 做沙箱隔离;
  • 不兼容过期的 stop-server PID 文件,除非评审人明确批准该权衡。

继承的安全不变量

本 fixup 完整保留上一轮认证加固设计的不变量,这些约束在后续每项修复中都必须继续成立:

  • .last-tokenstate/server-info 始终是敏感的、仅属主(owner-only)状态;
  • 回退令牌可以出现在启动 JSON 和 state/server-info 中,但不得写入 .last-token
  • Cookie 保持"按端口命名、HttpOnlySameSite=Strict、作用域 /";
  • WebSocket 升级仍然要求有效的 key 或 cookie;
  • 浏览器提供 Origin 头时,WebSocket Origin 检查仍然强制生效;
  • 不带 Origin 头的直连客户端仅在携带会话密钥时才被允许;
  • 生成的同源 screen JavaScript(及未来同源 vendored 库)可信,恶意 screen HTML 的沙箱化继续推迟。

2. 修复一:Rebase 到当前 dev,保持 PR diff 纯净

第一项修复是分支管理问题:在实施工作开始前,先把 brainstorming-companion 分支 rebase 到当前 origin/dev,并将 evals 子模块冲突解决为"取 dev"。rebase 之后必须满足:

  • evals 不出现在 PR diff 中;
  • PR #1720 仍可引用在别处跑过的 eval 证据,但必须给出精确的外部证据:eval 仓库 commit、scenario 路径、命令、结果工件路径或 id、RED/GREEN 结论;
  • PR 正文不得暗示 evals 子模块升级属于本 PR;任何早期暗示子模块升级包含在内的正文或评论,都必须被最终的 PR 正文证据取代。

对应实现计划(2026-06-11-visual-companion-final-hardening-fixup.md)中给出的解冲突命令为:

git restore --source=origin/dev --staged --worktree evals
git add evals
git rebase --continue

完成后可用 git diff --name-only origin/dev...HEAD -- evals 验证输出为空。

3. 修复二:根路由文件收容——getNewestScreen() 复用 /files/* 的边界守卫

问题

根路由 GET / 会从 content/ 目录中挑选最新的 .html 文件作为当前"屏幕"展示。如果 content/ 下存在指向目录外(例如 state/server-info)的符号链接或硬链接,且其 mtime 最新,根路由就会把目录外的敏感内容当作屏幕渲染出去——而 /files/* 路由早已具备收容保护,根路由却缺少同一道边界。

设计

设计要求根路由必须使用与 /files/* 相同的收容边界:getNewestScreen() 应忽略任何不通过"内容目录内常规文件"守卫的 .html 候选。该守卫必须解析真实路径(realpath),确认被提供文件确实位于 CONTENT_DIR 内,并且保留既有硬链接保护——当平台报告链接数时,拒绝链接数不为 1 的文件。

在当前仓库中,这一设计已经落地。server.cjs 中的 getNewestScreen() 在筛选 .html 候选时,先调用 isRegularFileInsideContentDir(fp),不过关的候选直接返回 null 被过滤掉:

function getNewestScreen() {
  const files = fs.readdirSync(CONTENT_DIR)
    .filter(f => !f.startsWith('.') && f.endsWith('.html'))
    .map(f => {
      const fp = path.join(CONTENT_DIR, f);
      if (!isRegularFileInsideContentDir(fp)) return null;
      return { path: fp, mtime: fs.statSync(fp).mtime.getTime() };
    })
    .filter(Boolean)
    .sort((a, b) => b.mtime - a.mtime);
  return files.length > 0 ? files[0].path : null;
}

而守卫本身 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;       // 硬链接(nlink > 1)拒绝
    realContentDir = fs.realpathSync(CONTENT_DIR);
    realFilePath = fs.realpathSync(filePath);  // 解析真实路径
  } catch (e) {
    return false;                              // 任何异常都 fail-closed
  }
  return realFilePath.startsWith(realContentDir + path.sep); // 必须在 CONTENT_DIR 内
}

要点在于 lstatrealpath 的分工:lstatSync 不做链接解引用,因此符号链接在第一步就被识别并拒绝;nlink !== 1 的判定覆盖硬链接逃逸;realpathSync 对真实路径做前缀比较,则堵住了"路径穿越后 realpath 仍落在目录外"这类边界情况。/files/* 路由(server.cjs)复用同一个守卫:空文件名、点开头文件、符号链接、硬链接、目录一律 404。

预期行为

  • content/ 下指向目录外的符号链接被忽略;
  • fs.linkSync 成功且 lstat.nlink > 1 时,指向 state/server-info 的硬链接被忽略;
  • 若没有任何安全的 screen 文件剩余,则提供等待页(waiting page);
  • /files/* 的既有收容行为保持不变:空名、点文件、符号链接、硬链接、目录仍返回 404。

4. 修复三:回退令牌隔离——EADDRINUSE 时按令牌来源分路处理

问题

服务在首选端口被占用时回退到随机端口。但如果回退进程直接复用了从持久化 .last-token 加载的令牌,就会出现两个存活的同一项目 companion 服务器持有同一个 bearer key——任何拿到其中一个 URL 的本地标签页都能认证到另一台服务器。

设计:令牌来源必须显式

设计将令牌来源分为三类,并在代码中显式追踪:

  • BRAINSTORM_TOKEN 环境变量:有意的运维/测试覆盖。如果设置了显式环境变量令牌而首选端口被占用,服务器必须 fail closed(拒绝回退直接退出),因为占用者很可能正在使用同一个显式令牌;
  • .last-token 文件:为"同端口重连便利"而持久化的状态。若服务器因首选端口被占用而回退,必须丢弃加载到的令牌,为回退进程生成一个全新的、不持久化的令牌;
  • 新生成的令牌(并非从 .last-token 加载而来)可以在同一进程内复用,因为没有已知的其他存活进程持有它。

此外,回退服务器必须继续避免覆盖 .last-port.last-token

源码印证

当前 server.cjs 中的 initialToken() 正是按"env → file → generated"三条路径显式返回 { value, source }

function initialToken() {
  if (process.env.BRAINSTORM_TOKEN) {
    return { value: process.env.BRAINSTORM_TOKEN, source: 'env' };
  }
  if (TOKEN_FILE) {
    try {
      const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim();
      if (/^[0-9a-f]{32,}$/i.test(t)) {
        chmodOwnerOnly(TOKEN_FILE);
        return { value: t, source: 'file' };
      }
    } catch (e) { /* no prior token recorded */ }
  }
  return { value: generateToken(), source: 'generated' };
}

const tokenInfo = initialToken();
let TOKEN = tokenInfo.value;
let tokenSource = tokenInfo.source;

EADDRINUSE 处理分支(server.cjs)据此分路:来源是 env 时打印错误并以非零码退出;来源是 file 时生成新令牌并标记 tokenSource = 'generated-fallback',然后监听随机端口(随机高端口由 randomPort() 产生,范围 49152–65534):

if (err.code === 'EADDRINUSE' && !triedFallback) {
  if (tokenSource === 'env') {
    console.error('Server failed to bind: preferred port is in use and BRAINSTORM_TOKEN is set; refusing fallback with explicit token');
    process.exit(1);
  }
  triedFallback = true;
  PORT = randomPort();
  if (tokenSource === 'file') {
    TOKEN = generateToken();
    tokenSource = 'generated-fallback';
  }
  server.listen(PORT, HOST, onListen);
}

与之配套的是 onListen 中的持久化抑制(server.cjs):只有拿到首选端口(!triedFallback)时,才会把绑定端口和令牌写入 PORT_FILE/TOKEN_FILE——回退时写入会覆盖共享文件,使另一会话已打开的浏览器标签页掉线。同时 Cookie 名 brainstorm-key-<实际绑定端口>实际绑定端口细化,避免与另一台服务器的 cookie 在同一 localhost cookie jar 中碰撞。

5. 修复四:Stop-Server 进程所有权证明——每次启动的实例 ID

问题

stop-server.sh 依据 PID 文件停止服务器。当强所有权证据(如 server-infolsof)不可用时,旧逻辑可能只凭"进程名叫 node server.cjs"就发信号——而 PID 文件在重启或 PID 回绕后可能指向一个完全无关的进程。

设计:惰性 argv 参数作为所有权凭证

start-server.sh 在每次启动时生成一个服务器实例 id,并将其作为惰性(inert)命令行参数传给 Node:

node server.cjs --brainstorm-server-id=<id>

该 id 不是认证凭证,只是本地生命周期脚本使用的进程所有权证据;server.cjs 可以完全忽略这个参数。id 必须使用 shell/MSYS 安全字母表,匹配 ^[A-Za-z0-9_-]{32,64}$,并以仅属主权限存入 state/server-instance-id

stop-server.sh 则读取状态目录中的期望 id,只有当目标进程 argv 中包含完整的 argv token --brainstorm-server-id=<id>(而不是松散的子串匹配)时才发送信号。命令行的读取优先使用 /proc/<pid>/cmdline(按 NUL 分隔逐 token 精确比较),不可用时回退到宽格式 ps 输出。匹配的实例 id 本身就是充分证明,即使 server-info 缺失或 lsof 不可用;既有的端口到 PID 检查可以作为附加证据保留。

Fail-closed 与运维可见的结果

所有权无法证明时一律 fail closed,覆盖五种情形:PID 文件缺失、server id 缺失或格式错误、目标命令行不可用、目标命令行不含期望 id、以及没有新 id 的旧/陈旧会话元数据。设计明确偏好"让陈旧进程继续运行",而不是"杀掉无关进程"。

运维可见的结果被规范为明确的输出:

情形 输出
PID 文件缺失 not_running
server id 缺失或格式错误 stale_pid
目标命令行不可用 stale_pid
argv 中 id 错误或缺失 stale_pid
成功停止 stopped

stale_pidstopped 两种结果下,删除 server.pidserver-instance-id,防止后续 stop 尝试反复瞄准同一个含糊进程;但不删除持久化的会话内容。

源码印证

start-server.sh 中可以看到 id 的生成与落盘:优先从 /dev/urandom 读取 24 字节十六进制(48 字符),若校验 ^[A-Za-z0-9_-]{32,64}$ 失败则用 $$、时间戳和 $RANDOM 拼出 32 位十六进制兜底;写入 state/server-instance-id 后执行 chmod 600。两条 Node 启动命令(前台与 nohup 后台)都在 server.cjs 之后传递该参数(start-server.shL180),确保 argv token 完整:

nohup env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs "--brainstorm-server-id=$SERVER_ID" > "$LOG_FILE" 2>&1 &

stop-server.sh 实现了完整的校验链:read_expected_server_id() 从状态文件读取并校验 id 字母表;command_has_server_id()/proc/$pid/cmdline 可读时按 NUL 分隔逐 token 精确比对 --brainstorm-server-id=$expected,否则回退到 ps -ww/ps -f 的宽输出并做带边界的子串匹配(前后加空格防止前缀误匹配);is_brainstorm_server() 要求目标 PID 存活、期望 id 可读且 argv 匹配三者同时成立。校验失败即走 stale 分支:

if ! is_brainstorm_server "$pid"; then
  rm -f "$PID_FILE" "$SERVER_ID_FILE"
  mark_stopped "stale_pid"
  echo '{"status": "stale_pid"}'
  exit 0
fi

成功停止后同样清理元数据(stop-server.sh):rm -f "$PID_FILE" "$SERVER_ID_FILE" "${STATE_DIR}/server.log",并仅在会话目录位于 /tmp 下时删除临时目录,持久化的 .superpowers/ 内容保留以供事后查看。

6. 修复五:测试加固——跨 macOS 与 Windows Git Bash 的确定性

设计第五项要求测试在 macOS 与用于验证的 Windows Git Bash 主机(验证主机名为 ballmer)上都是确定性的,具体要求:

  • 固定端口套件:如果服务器报告了回退端口,必须快速失败;或者所有客户端都从"报告的启动端口"发起连接——不允许静默测到回退进程上;
  • stop-server.test.sh:在任何后台进程启动之前,必须有一个顶层 cleanup trap,防止失败时泄漏后台进程;
  • 符号链接断言:应先探测主机的符号链接能力,仅在该主机无法创建可用测试符号链接时跳过那一条断言,而不是跳过整个套件;
  • 冒充者(impostor)测试:测试中创建的冒充进程,必须在生命周期元数据缺失或不足时断言其存活(即 stop 不得杀掉它);
  • Windows/MSYS 启动测试:必须断言 Windows 类检测仍会清除 BRAINSTORM_OWNER_PID、在适当时机自动前台化、并且精确传递实例 id argv。

这套要求与 start-server.sh 的实际行为一致:is_windows_like_shell() 通过 OSTYPE/MSYSTEM/uname -s 识别 MSYS/MINGW/CYGWIN 环境;检测命中时自动转前台(Git Bash 会收割 nohup 后台进程),并在 Windows 类 shell 下清空 OWNER_PID——因为 Node 无法看到 MSYS2 PID 命名空间中的 POSIX PID,错误的 PID 会导致服务器在生命周期检查中自我终止,此时 idle 超时就成为唯一的关闭触发器。

7. 修复六:文档与 PR 一致性

在最终评审之前,需要把评审者可见的文档与 PR 元数据对齐:

  • 更新问题目录(issue catalog),使各项处置(disposition)与 PR 实际交付一致;
  • 让自动打开(auto-open)相关文档与实现的 --open 行为保持一致(例如 visual-companion.md 中启动用户已批准的 companion 会话的平台命令带上 --open,而远程绑定示例因有意跳过自动打开而不加);
  • 所有地方保持默认 idle 超时的文档值为 4 小时(与 start-server.sh 注释中 --idle-timeout-minutes 默认 240 分钟一致);
  • rebase 之后按模板复核 PR 正文;
  • 在 PR 正文中记录 macOS、Windows、浏览器/手工、外部 eval 证据,附具体命令与结果。

8. 测试策略:TDD 回归矩阵

设计为每个行为变更采用 TDD:先新增或收紧一个聚焦回归测试,运行并确认它因预期原因失败(RED),实现最小修复,重跑聚焦测试,再重跑完整的 brainstorm-server 套件。

必测回归矩阵

行为 测试文件 聚焦命令 预期 RED 预期 GREEN
根路由忽略符号链接逃逸 tests/brainstorm-server/server.test.js node tests/brainstorm-server/server.test.js 认证后的 GET / 提供了被链接的目录外内容 响应提供等待页或安全屏幕
根路由忽略受支持的硬链接逃逸 tests/brainstorm-server/server.test.js node tests/brainstorm-server/server.test.js 认证后的 GET / 提供了硬链接的 server-info nlink > 1 时硬链接候选被忽略
/files/* 收容保持不变 tests/brainstorm-server/server.test.js node tests/brainstorm-server/server.test.js 既有收容测试回归 空名、点文件、目录、符号链接、硬链接用例仍 404
持久化令牌回退时轮换令牌 tests/brainstorm-server/lifecycle.test.js node tests/brainstorm-server/lifecycle.test.js 回退 URL key 等于持久化的首选端口 key 回退 URL key 不同且未写入 .last-token
显式令牌回退 fail closed tests/brainstorm-server/lifecycle.test.js node tests/brainstorm-server/lifecycle.test.js 设置了 BRAINSTORM_TOKEN 时服务器仍回退 进程非零退出且不启动回退
回退 key 无法认证到原服务器 tests/brainstorm-server/lifecycle.test.js node tests/brainstorm-server/lifecycle.test.js 回退 key 从原端口得到 200 原端口拒绝回退 key
正确实例 id 允许停止 tests/brainstorm-server/stop-server.test.sh bash tests/brainstorm-server/stop-server.test.sh 真实由 start-server 启动的服务器存活 stop 返回 stopped 且进程退出
错误/缺失/畸形/陈旧 id 是安全的 tests/brainstorm-server/stop-server.test.sh bash tests/brainstorm-server/stop-server.test.sh 冒充者被发信号 stop 返回 stale_pid 且冒充者存活
固定端口套件不能经回退通过 tests/brainstorm-server/server.test.jstests/brainstorm-server/auth.test.js 各自的 node 命令 测试静默地测到回退端口 测试明确失败,或有意使用报告端口
Shell 清理 trap 在失败时执行 tests/brainstorm-server/stop-server.test.sh bash tests/brainstorm-server/stop-server.test.sh 失败时留下子进程 trap 收割后台子进程
Windows/MSYS 启动行为保持生命周期不变量 tests/brainstorm-server/start-server.test.shtests/brainstorm-server/windows-lifecycle.test.sh 在 macOS 和 ballmer 上运行 bash 测试命令 owner PID 或 argv 处理回归 owner PID 被清除、前台检测成立、id argv 存在

仓库中的回归测试落点

当前仓库中上述回归均已存在:server.test.js 包含"根路由符号链接逃逸"与"根路由硬链接逃逸"两条测试;lifecycle.test.js 包含"持久化令牌回退生成新的非持久化 key"与"显式 BRAINSTORM_TOKEN 回退 fail closed"测试;stop-server.test.sh 包含"匹配实例 id 的真实服务器被停止"(约 L63)与"缺失/错误/畸形 id 的冒充者被放过"(约 L126-L175)等用例。每个 RED/GREEN 循环都应为 PR 正文留下简短证据记录:聚焦命令、修复前的失败断言、修复后的通过断言,以及证据采集于 macOS 还是 Windows。

9. 验证清单与验收标准

完成前验证命令

声明 fixup 完成之前,需依次执行:

git fetch origin dev && git rebase origin/dev
git diff --quiet origin/dev...HEAD -- evals
gh pr view 1720 --json mergeStateStatus,statusCheckRollup,headRefOid
cd tests/brainstorm-server && npm test
# 以及 TDD 期间使用的各聚焦测试命令
git diff --check

外加:触及的 JavaScript 文件做 Node 语法检查、触及的 shell 文件做 shell lint(可用 scripts/lint-shell.sh)、在 ballmer 上做 Windows 验证(完整可运行的 brainstorm-server 套件加独立 Windows 生命周期探测)。浏览器/手工测试只在自动化套件全绿之后进行。

验收标准

  • PR #1720 干净地 rebase 到当前 dev
  • evals 不出现在 PR diff 中;
  • 根路由屏幕提供无法通过符号链接或受支持的硬链接逃逸读取 content/ 之外内容;
  • /files/* 收容保护保持不变;
  • 没有任何回退服务器运行在可能与首选端口服务器共享的令牌上;
  • stop-server.sh 在所有权证据缺失或含糊时不向无关进程发信号;
  • 即使 server-infolsof 不可用,只要实例 id 匹配,stop-server.sh 仍能停止合法服务器;
  • 每个回归都有聚焦的 RED/GREEN 证据记录;
  • PR 正文记录了 macOS 与 Windows 验证证据;
  • PR 正文准确描述分支内容与在外部采集的证据。

10. 小结:三层防御的组合逻辑

这轮最终加固的核心价值在于把"本地看似无害的三个机制"分别钉死在显式边界上:根路由选屏/files/* 共用同一个"常规文件 + nlink=1 + realpath 前缀"守卫(server.cjs);端口回退按令牌来源(env/file/generated)显式分路,宁可拒绝启动也不复用可能共享的 key(server.cjs);进程停止从"进程名匹配"升级为"每次启动唯一 argv 凭证的精确 token 匹配",一切含糊都 fail closed 为 stale_pidstop-server.sh)。配合固定端口守卫、cleanup trap 与符号链接能力探测,整套生命周期脚本在 macOS 与 Windows Git Bash 上都能给出确定、可审计、且"宁可漏杀、绝不误杀"的安全行为。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
903
1.82 K
docsdocs
暂无描述
Markdown
888
5.78 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.51 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384
flutter_flutterflutter_flutter
本仓库是 Flutter SDK 与 Flutter Engine 的 OpenHarmony 适配版本,由 CPF-Flutter 团队维护。开发者可使用熟悉的 Flutter 技术栈开发 OpenHarmony 应用,3.35.7 及以后的适配版本可基于本仓库源码构建支持 OpenHarmony 的 Flutter Engine。
Dart
1.17 K
341