fix(sandbox): 收口 Linux lark-cli keystore 跨 bot 泄漏 - #777
Conversation
阻断性复审结论(HEAD
|
二轮复审:仍有 1 个安全 blocker(HEAD
|
lark-cli 在 Linux 把密钥写到共享的 `${XDG_DATA_HOME:-$HOME/.local/share}/lark-cli`
(`appsecret_<appId>.enc` 每 app 一份 + 共享 `master.key`),而 Linux fs-policy 只有
一条 baseline `ro(~/.local/share)`、没有更深的 deny——于是任何开了 sandbox/readIsolation
的 bot 都能只读到**所有兄弟 bot 的 appsecret 密文 + master key**,可改写自身 config 指向
兄弟 appId 从而冒用其身份。macOS 早已在 `~/Library/Application Support/lark-cli` 上闭合
(deny 整目录 + 深一层只放行自身 master.key + 自身 appsecret),Linux 漏写了这半边。
改动:
- fs-policy.ts:Linux transport 会话镜像 macOS carve-out——`deny(store 根)` +
深一层 `ro(master.key)`(Linux 文件名是 `master.key`,非 macOS 的 `master.key.file`)
+ `ro(appsecret_<self>.enc)`,靠 longest-prefix-wins 只放行自身两样、兄弟密文与用户
token 全 deny。
- computeNoTransportAuthorityRoots:把该 store 纳入 no-transport authority roots——
apiOnly / HTTP 虚拟会话彻底冻结整个 keystore、**不发任何 carve-out**(连自身 key 也不给),
堵住底层 policy 在最严格模式下仍能只读共享 keystore 的缺口。
- store 路径按 XDG 规范由 worker 从**冻结宿主环境**解析(绝对 XDG_DATA_HOME 才认、相对值
按 spec 忽略回退 $HOME/.local/share)+ canonical 后作为显式 FsPolicyContext 输入;
policy 保持纯函数、不读 env。新增纯函数 resolveLarkCliLinuxStoreDir / larkCliLinuxStorePath
便于单测。
影响面:仅 Linux 且开 sandbox/readIsolation 的会话行为变化(收紧);darwin 路径完全不变
(新分支 `platform==='linux'` 才进);no-transport(apiOnly) 收紧;PtyBackend/TmuxBackend
无关(policy 编译层共用,两引擎都按 longest-prefix 语义)。
验证:
- pnpm build 绿。
- fs-policy 单测 +7(默认 store 自身 RO / 兄弟+token+未来文件 deny / 无关工具链不误伤;
自定义 XDG 生效;no-transport 全 deny + 敌意 user RW 压不开;XDG 解析真值表)。
- bwrap 内核级 e2e +1:真 bubblewrap 挂载下兄弟密文 + master.key 读不到、自身 key 可读、
store 不可写、ls 不列兄弟文件。
- 变异测试:去 store deny / 破 no-transport 门 / 碰错 master.key 文件名,三处各让对应
测试翻红(含内核级那条),确认新测有牙。
- 触及及沙盒相关 12 套件 237 passed(darwin seatbelt e2e 在 Linux 正确跳过)。
- live:真 sandbox 内 `lark-cli auth scopes` 仍可用(自身 app 解密出 158 scopes),
同时兄弟 appsecret 读取被内核级拦截;no-transport 下自身 key 亦被冻结。
…HOME 嵌套 authority 逃逸 复审发现前一版两处安全边界未闭合,本 commit 收口: P1-1 store 真值源错误。lark-cli 在 Linux 由 `LARKSUITE_CLI_DATA_DIR` 决定 keystore 落点(`<value>/lark-cli`,仅当绝对路径;相对/空/未设回退 `$HOME/.local/share/lark-cli`), **完全不读 `XDG_DATA_HOME`**(strace 实证 v1.0.76 + 对齐官方 keychain_other.go::StorageDir)。 前一版 resolver 读的是 XDG,会冻结错目录 → 真 store 仍暴露、泄漏重开。 - resolveLarkCliLinuxStoreDir 改读 LARKSUITE_CLI_DATA_DIR;worker 传 process.env.LARKSUITE_CLI_DATA_DIR。 - bwrap 无 `--clearenv`,子进程会继承该变量,且 sandbox 的 `--setenv` 白名单不含它 → 沙盒内 lark-cli 读的就是 worker 自身 env 值,冻结同一值即 policy==CLI 同一权威路径。 - sandbox.ts 追加:绝对值 `--setenv` 重钉、否则 `--unsetenv`,把子进程 keystore 解析 锁死到 policy 冻结的同一目录,免疫 tmux pane rc / 继承漂移注入的分叉值。 P1-2 BOT_HOME 嵌套 authority 逃逸。若 `LARKSUITE_CLI_DATA_DIR=<BOT_HOME>` 使 store 落在 自身 BOT_HOME 内,旧 `insideAuthority = under(anyRoot) && !under(botHome)` 的 BOT_HOME 全量例外会让更深的 hostile user RW 逃过 dropAuthority → `master.key` 最终解析为 readWrite (复审已复现)。改为**按 root 作用**:BOT_HOME 例外只对「作为 BOT_HOME 祖先的 root」生效; 嵌套在 BOT_HOME 内、比它更具体的 authority root(keystore)仍照常限制。 验证: - pnpm build 绿;fs-policy 单测 79 passed(+resolver 真值表改 DATA_DIR、+transport/no-transport 两条 keystore-nested-in-BOT_HOME 回归,覆盖 user RW/RO + readonlyRoots + extraWrite 四通道)。 - bwrap 内核级 e2e +1:DATA_DIR 落 BOT_HOME + no-transport + 敌意 user RW 直指 master.key, 真 bubblewrap 下 master.key/兄弟密文仍内核级不可读不可写,BOT_HOME 其余 scratch 仍可写。 - 变异测试:resolver 把相对当绝对 → 真值表红;还原旧 insideAuthority → 嵌套逃逸单测 + 内核级 e2e 均红(坐实两测各自有牙,且旧漏洞真能被复现)。 - 沙盒相关 12 套件 240 passed;sandbox.ts 相关 35 passed(新 --setenv/--unsetenv 未破坏断言)。 - live:真 sandbox + LARKSUITE_CLI_DATA_DIR 指向重定向 store,`lark-cli auth scopes` 出 158 scope(自身 auth 从重定向 store 正常),植入的兄弟密文内核级被拦(rc=1)、ls 不列兄弟。 影响面同前:仅 Linux 且开 sandbox/readIsolation 收紧;darwin 分支不变;no-transport 收紧。
…R 纳入 per-bot reserved
三轮复审两处收口:
P1-3 canonical/TOCTOU 静默回退错 store。lark-cli 的 SafeEnvDirPath 对合法绝对
LARKSUITE_CLI_DATA_DIR 先 `filepath.Clean`(解析 `..`/`.`/`//`、控制字符判无效回退),
再 canonicalize。前一版 resolver 只 `startsWith('/')` 不 Clean,worker 的 full-path
realpath 在 store 叶子尚不存在时抛错、原样保留含 `..` 的值,随后 normalizeFsPath 因 `..`
拒绝并**静默回退默认 store** → policy 冻结/deny 的是默认目录,而 lark-cli(会 Clean)打开
的是真实目录 → 真 store 在 broad workingDir 下有效权限 readWrite(复审已复现)。同一根因
覆盖「symlink 祖先 / symlink HOME 且 store 尚不存在」的命名空间分叉。
- 新增纯函数 cleanPosixAbsPath:Go filepath.Clean 语义 + 控制字符拒绝,对齐 SafeEnvDirPath。
- resolveLarkCliLinuxStoreDir 用它 Clean 合法绝对值(strace 实证 parity:`/srv/a/../b`
→ policy 与 lark-cli 都落 `/srv/b/lark-cli`)。
- worker 新增 canonicalNearestAncestor:realpath 最深存在祖先(解析 symlink 前缀)+ 回接
不存在尾段,替代会在缺失叶子上抛错的 full-path realpath。
- larkCliLinuxStorePath 对不可规范化 override 返回 null(发不出 carve-out、store 保持
deny-by-default),**绝不静默回退默认 store**。
P2 per-bot env 静默覆盖。LARKSUITE_CLI_DATA_DIR 之前被 sanitizePerBotEnv 接受,但沙盒的
host `--setenv`/`--unsetenv` 会静默盖掉 per-bot 值(误导),且若在非沙盒路径生效会把 keystore
挪出冻结 policy。纳入 per-bot RESERVED_ENV_KEYS:配了即拒 + 可诊断,不再静默吞。
- sandbox.ts 的 child-pin 也改用 cleanPosixAbsPath:合法绝对 → `--setenv` 规范化值;否则
`--unsetenv` 回退默认,保证沙盒内 lark-cli 与 policy 落同一 store。
前一轮结论:P1-1(变量 XDG→LARKSUITE_CLI_DATA_DIR) 与 P1-2(BOT_HOME 嵌套 authority per-root
判定) 复审已确认修正正确,本 commit 不改动其结论。
验证:
- pnpm build 绿。
- fs-policy 单测(+cleanPosixAbsPath 真值表、resolver 的 `..`/`//`/root-clamp/控制字符矩阵、
larkCliLinuxStorePath 不可规范化→null);per-bot-env 单测(LARKSUITE_CLI_DATA_DIR 被 drop +
isReserved=true)。
- 变异测试:去掉 resolver 的 Clean → SafeEnvDirPath 真值表翻红;从 reserved 去掉该键 →
per-bot 测试翻红(两测各有牙)。
- 沙盒+env 相关 12 套件 239 passed。
- strace parity:`/srv/a/../b` 下 policy resolver 输出 == lark-cli 实际 open 的路径。
- live:raw `..`-bearing LARKSUITE_CLI_DATA_DIR 走完整 worker resolve+nearest-ancestor
canonicalize → 真 bwrap 内 lark-cli auth scopes 出 158(自身从 Clean'd store 正常 auth)、
植入兄弟密文内核级 rc=1 拦。
影响面:仅 Linux 且开 sandbox/readIsolation 收紧;darwin 不变;per-bot env 多 reserve 一个键。
b3912a9 to
43cbae1
Compare
背景 / 问题
沙盒的白名单模型设计上按「每个 bot 只放行自己那份 lark-cli 密钥」,macOS 早已在 keystore 上闭合,但 Linux 漏写了这半边。
lark-cli 在 Linux 把密钥落在共享 store
${XDG_DATA_HOME:-$HOME/.local/share}/lark-cli:而 Linux
fs-policy只有一条 baselinero(~/.local/share)、没有更深的 deny,于是任何开了sandbox/readIsolation的 bot 在沙盒内都能只读到所有兄弟 bot 的appsecret_*.enc+ 共享master.key,可改写自身 config 指向兄弟 appId/keyId 从而冒用兄弟 app 身份。LARKSUITE_CLI_CONFIG_DIR只重定向 config、不搬 data,所以没有任何推荐配置能把 Linux 密钥搬成 per-bot。macOS 对照:
~/Library/Application Support/lark-cli整目录deny+ 深一层只放行自身master.key.file+ 自身appsecret_<self>.enc——本 PR 就是把这段镜像到 Linux。额外发现:
no-transport(apiOnly / HTTP 虚拟会话,本该冻结全部飞书凭证)下,该 store 之前仍是只读——底层 policy 在最严格模式下都存在 Linux 跨 bot 密钥泄漏。改动
fs-policy.ts— Linux transport 会话镜像 macOS carve-out:deny(store 根)(deeper than baselinero(~/.local/share)→ longest-prefix 赢)+ 深一层ro(master.key)(Linux 文件名是master.key,非 macOS 的master.key.file)+ro(appsecret_<self>.enc)。兄弟密文、用户 token、未来新文件全 deny-by-default。computeNoTransportAuthorityRoots— 把该 store 纳入 no-transport authority roots:apiOnly / HTTP 虚拟会话彻底冻结整个 keystore、不发任何 carve-out(连自身 key 也不给),并靠已有的dropAuthority压住敌意 user RW/RO 与宽 workingDir 的重开洞。XDG_DATA_HOME才认、相对值按 spec 忽略回退$HOME/.local/share)+canonical()后作为显式FsPolicyContext.larkCliLinuxStore传入;policy 保持纯函数、绝不读 env。新增纯函数resolveLarkCliLinuxStoreDir/larkCliLinuxStorePath便于单测。因沙盒子进程原样继承宿主HOME/XDG_DATA_HOME(worker 从不改写),解析出的正是沙盒内 lark-cli 会读的同一目录。影响面评估
sandbox/readIsolation的会话行为变化(收紧)。darwin 路径完全不变——新逻辑在platform === 'linux'分支内,darwin 分支与断言一字未动。PtyBackend/TmuxBackend共用同一编译产物,两引擎都按 longest-prefix 语义,无差异。fs-policy.ts,不涉及任何单个 CLI 适配器;所有 20+ CLI 只要开沙盒都受同一(收紧后的)keystore 规则保护。测试验证
pnpm build✅XDG_DATA_HOMEstore 随之 rehome;no-transport 全 deny + 敌意 user RW 压不开;resolveLarkCliLinuxStoreDir的「绝对认/相对忽略/未设回退」真值表。appsecret+master.key内核级读不到、ls不列兄弟文件、自身 key 可读、store 不可写(白盒 deny→ro 父→ro 自身 key 的嵌套 carve-out 挂载真实性)。master.key.file,三处各让对应测试翻红(含内核级那条),确认新测有牙、非假绿。lark-cli auth scopes仍可用(自身 app 解密出 158 个 scope),同时兄弟appsecret读取被内核级拦截("No such file",rc=1);no-transport 下自身 key 亦被冻结(BLOCKED)。Refs #701(该文档 PR 保持 docs、阻塞到本代码修复合入后再 rebase 修正 Linux 密钥落点表述 + P2 首次 init 只能宿主沙盒外)。