fix(dashboard): Devbox 导出解析修复 fail-open,按 stdout/stderr 分流 - #1071
fix(dashboard): Devbox 导出解析修复 fail-open,按 stdout/stderr 分流#1071swtxbling wants to merge 2 commits into
Conversation
接上一条修复,收口对抗审查发现的问题:
- 导出输出此前把 stdout 与 stderr 拼成一个字符串再解析,噪声与结果同流,导致要么
被噪声带偏、要么为了防歧义把正常输出一并拒绝。改为保留 `{ stdout, stderr }` 边界:
解析结果三态(命中 / 拒绝 / 无结果形状),stdout 一旦判定歧义即 fail closed、
绝不回落 stderr,只有 stdout 确实没有结果形状时才读 stderr 兼容旧形态。
实测 8 类 stderr 噪声(含 Go `%v` 结构体打印、Python 风格字典、带冒号的花括号提示、
不配对花括号)不再影响解析,而 stdout 公开结果配 stderr 私有结果等反例仍返回 null。
- 补齐解析器对明文 key=value、JSON 字符串值内藏 verdict、Unicode 转义键名、重复与
嵌套 security 字段等形态的判定,避免公开结论被误判成「无结果」而被后续私有候选顶替。
- 歧义拒绝路径补 debug 日志,只记录固定原因,不记录候选文本与 URL;此前失败全程静默,
真机无从判断是未启用还是被解析器拒绝。
- 启动提示里剩余预算不足 500ms 时直接跳过导出,不再发起注定超时的 spawn,
也不会因此点亮 60s 失败负缓存。
- `~/.botmux/.env` 兜读改为显式 `envFileMode`,默认始终读取;此前靠「调用方是否传入
env」推断,任何传 `env: process.env` 的改写都会让关闭开关在 CLI 侧无声失效。
测试通过显式 ignore 隔离,不再受开发机真实 .env 影响。
验证:tsc --noEmit exit 0;audit:domains 无内网域名泄漏;git diff --check 干净;
直接相关 7 个测试文件 207 项全绿;波及面 22 个测试文件 867 项全绿;带干扰 HOME
复跑解析用例 54 项全绿;完整 unit 套件 18754 项中 41 项失败,与本分支 base commit
上的失败集合逐条一致(本机既有失败,与改动无关)。变异测试确认新用例有效:
让 stdout 拒绝回落 stderr、取消 500ms 下限、把 .env 兜读改回按 env 推断,
三处分别转红。
该用例的 `runExport` 返回的是裸字符串,而注入点的契约已经是
`{ stdout, stderr }`。裸字符串上取 `.stdout` 得到 undefined、扫描时抛错,
于是它落进的是「runner 抛异常」那条分支,覆盖的是另一个负缓存写入点。
把「解析失败也要写负缓存」那几行删掉后整个文件依然全绿,变异存活。
同时补 `envFileMode: 'ignore'`:不加时开发机上真实的 ~/.botmux/.env 若含
`BOTMUX_DEVBOX_AUTO_EXPORT=0`,用例会因为开关关闭而根本不 spawn,
断言 1 次调用变成 0 次。
注:`tsconfig.json` 的 include 只有 `src/**/*`,测试文件不进 `tsc --noEmit`,
所以这处签名不一致不会被类型检查发现,只能靠变异测试暴露。
修好后:删掉解析失败那条写入点该用例转红;带干扰 HOME 复跑 56 项全绿。
|
感谢这个 PR — 核心的 fail-open 我复现确认属实,是真问题、值得修。下面是自动评审的初步意见,最终以维护者审阅为准。结论是 🟠 建议改后再合:方向对,但有两条实测缺陷,其中一条是可用性回归。 所有结论都在本地实跑过(PR head ✅ 先确认成立的部分1. fail-open 是真的。 拿 master 逐字副本做差分,PR 描述里那个输入确实复现: 2. 未闭合花括号的复杂度修复,效果很实。 原来是 O(n²): 3. 「导出挪到 endpoint 之后」的理由属实。 4. argv 行为零变化( 5. 安全护栏大部分有牙。 8 组反向变异,6 组能让作者自己的用例转红:
🔴 建议修改(1):解析收太严,正常输出被判死PR 描述说这四类噪声「此前会让整条解析失败……现在不影响」。这个前提反了。 我把这四类逐条喂给 master: 更大范围扫 10 类噪声 × 2 个位置:master 0 处失败,本 PR 14 处失败,0 处修复。 为什么测试没发现:这四类只在 stderr 那组用例出现(结果在 stdout,被分流天然隔离,此时噪声根本没被解析);而 影响不限于 stdout。 PR 自己保留了 stderr 兜底(「只有 stdout 确实没有结果形状时才读 stderr」),说明不假设 merlin-cli 一定把结果放 stdout。而「结果在 stderr,噪声也在 stderr」——一个把所有输出都写 stderr 的 CLI 的自然形态——12 种组合里 8 种失效。 根因是 建议修法(我验过):只删掉那个裸 - if (candidateText.includes(':')
- || containsRawSecurityField(candidateText)
+ if (containsRawSecurityField(candidateText)
|| keyCounts.shortUrl > 0
|| keyCounts.isPublic > 0) {实测结果:
残留缺口我也如实列出来:删掉后, 🟠 建议修改(2):memo 指纹引入了 ~50% 的测试 flake
后果是 A/B 定位:在两次读之间插一句 严重度我要压住,避免夸大:生产写这两个文件都是原子写—— 顺带一个数据点:memo 从 TTL 改指纹后,它想保护的那条 CSRF 热路径反而更贵了(每次命中都要付 3 次 一个两全的形态是 TTL + 指纹:TTL 内直接返回,TTL 到期后再用指纹决定要不要重算 —— 既保住「跨进程导出下一次调用就可见」的新鲜度,又不用每次控制请求都付 stat。 🟡 可选(3):导出后那次刷新不受预算约束
(另:我一度以为这里相对 master 是改进,复核后撤回——master 的 小建议: 建议的最小可合集合
(3)可以另开 issue 跟进。 以上是自动评审的初步意见,可能有误判 —— 尤其 merlin-cli 的真实输出格式我这台机器上无法验证(没有 |
|
复审补充(pi,独立复跑确认)——两条缺陷结论都成立,维持 🟠 建议改后再合。对首审建议的修法 #1 做一点修正,请以此为准: 删掉裸 单引号 + Unicode 转义下划线( 建议的修法(替代「裸删」):把「含冒号」换成「含 security marker 的近似拼写」——对 malformed candidate 先归一化(解码 其余复核结论:memo 两个写点 repo 内确认只有 dashboard 端口自愈与 cache 写入、均为原子写(与首审一致);TTL+指纹建议成立,唯一实现注意点是时间戳只能在真正重算指纹时更新(不能每个热命中续期,否则持续热的进程永不重校验);导出后那次 最终以维护者审阅为准。 |
|
更正与补充(承接我上一条 #issuecomment-5460766487,以及复审者的 #issuecomment-5460801952)。 🔴 我上一条有一处说错了,在此更正我原话是「只删裸 camelCase / Go 我原来的论证是「这些本来就判不出 verdict,所以无害」——错在这里:verdict 行的存在性本身今天就是一个 fail-closed 信号,不需要能读出它的值。感谢复审者揪出来。 (公平起见也记下边际:假私有 log 单独出现时当前实现同样放行,跨流两边也都拦不住。所以是「零安全损失」这句话错了,不是「该项不该动」——它确实同时造成了 14 处可用性回归。) ✅ 复审者提的替代修法:我实装并验证了,可用不裸删,改成对 malformed candidate 归一化后匹配 marker(解 我把它实装进 再叠加放宽「未闭合花括号」那条(同样只在归一化后含 marker 时才拒),可用性升到 13/15,安全仍 0/24 泄漏。 完整 patch(两处改动 + 一个 helper): +/** Normalize an unparseable candidate so a verdict spelled in a non-JSON
+ * dialect (camelCase, Go `%v`, single quotes, \uXXXX escapes) is still
+ * recognized as result-shaped. Only used to decide whether a malformed
+ * candidate must fail closed; noise without a marker stays skippable. */
+function malformedCandidateLooksResultShaped(text: string): boolean {
+ const decoded = text.replace(/\\u([0-9a-fA-F]{4})/gu, (_m, hex: string) =>
+ String.fromCharCode(parseInt(hex, 16)));
+ const flattened = decoded.toLowerCase().replace(/["'\s]/gu, '');
+ return /short_?url/u.test(flattened) || /is_?public/u.test(flattened);
+}
if (candidate.end === null) {
- return rejectExportOutput(source, 'unterminated object candidate');
+ // Markers inside the tail are still caught by the trailing scan below
+ // (scannedThrough is not advanced), and a marker-free tail carries no
+ // verdict, so an unclosed brace alone must not poison the whole stream.
+ if (malformedCandidateLooksResultShaped(candidate.text)
+ || containsRawSecurityField(candidate.text)) {
+ return rejectExportOutput(source, 'unterminated result-shaped candidate');
+ }
+ continue;
}
- if (candidateText.includes(':')
+ if (malformedCandidateLooksResultShaped(candidateText)
|| containsRawSecurityField(candidateText)
|| keyCounts.shortUrl > 0
|| keyCounts.isPublic > 0) {剩余 2/15 如实交代: 🟡 另一处补充(复审者指出,我认同)我上一条给的「wedged server 卡 2504ms」这个数字偏小:那是我的探针自己 abort 的结果,不是真实上界。裸 fetch 无超时,undici 默认 关于 memo(补一个我没点破的点)复审者提醒得对:master 的 5s TTL 把陈旧上界钉死了;本 PR 纯指纹一旦撞帧,若之后无人再写该文件,可以无限期 stale。概率极低但后果无界——这反而是 TTL+指纹的又一条理由。TTL+指纹的实现注意点:时间戳只能在真正重算时更新,不能每次热命中续期,否则持续繁忙的进程永远不会再校验文件。 修改后的最小可合集合:
以上仍是自动评审意见,最终以维护者审阅为准;真机 merlin-cli 的输出/拼写形态我们两边都无法验证,这是本轮最大的未知。 |
|
复审终验(对上一条 patch 的独立实装复核)——patch 方向确认可用,但发现一个 bypass,需改一行正则: 连字符拼写打穿现行 patch: - return /short_?url/u.test(flattened) || /is_?public/u.test(flattened);
+ return /short[-_]?url/u.test(flattened) || /is[-_]?public/u.test(flattened);加这行后独立复跑(fresh module state per case,规避负缓存串扰): 剩余 1/15 与上一条交代的一致:未闭合 另给后续验证者的一个方法论提醒:验证「删/改 guard 无损」时,每例之间要隔离模块级负缓存( 以上为自动评审,最终以维护者审阅为准。 |
|
第三次更正 / 修法收敛(承接 #5460824402 与复审者的 #5460839332)。 连字符 bypass 确认属实——但一行修不够,还漏 4 种复审者报的 我上一条贴的 patch(下划线正则)实测 7/12 泄漏: 再实测复审者建议的一行 连字符确实堵上了,但 根因是修法的形状不对:分隔符是开放集合,用允许列表( 改成分隔符无关(不再枚举)反过来做:解转义后剥掉所有非字母数字,再找 marker 词本身。这对任意分隔符、引号、大小写都封闭。 +function malformedCandidateLooksResultShaped(text: string): boolean {
+ const decoded = text.replace(/\\u([0-9a-fA-F]{4})/gu, (_m, hex: string) =>
+ String.fromCharCode(parseInt(hex, 16)));
+ const letters = decoded.toLowerCase().replace(/[^a-z0-9]/gu, '');
+ return letters.includes('shorturl') || letters.includes('ispublic');
+}
if (candidate.end === null) {
- return rejectExportOutput(source, 'unterminated object candidate');
+ if (malformedCandidateLooksResultShaped(candidate.text)
+ || containsRawSecurityField(candidate.text)) {
+ return rejectExportOutput(source, 'unterminated result-shaped candidate');
+ }
+ continue;
}
- if (candidateText.includes(':')
+ if (malformedCandidateLooksResultShaped(candidateText)
|| containsRawSecurityField(candidateText)
|| keyCounts.shortUrl > 0
|| keyCounts.isPublic > 0) {实测(每例 fresh module + 成功 canary 交错): 残留代价,如实交代1. 含 marker 词的散文会被误拒: 2. 未闭合 方法论(复审者提的,我确认并补一条)
修改后的最小可合集合(仍是 2 条):
仍是自动评审意见,最终以维护者审阅为准。真机 |
|
复审第四轮(对「分隔符无关」patch 的独立实装复核,每例 fresh module + 成功 canary): 确认:ASCII 分隔符维度确实封闭——12 类分隔符/拼写攻击我复跑全拦(连字符/dot/双下划线/斜杠/冒号/空格/混合大小写),散文误拒两版等价、散文无 marker 正常放行,作者 56 项全绿,tsc exit 0。赞成「不枚举分隔符、反向剥非字母数字」这个形状修正——允许列表确实每漏一个就是一个静默 bypass。 但「这一维已封闭」我打穿了,4 条 LEAK: 机理:① 同形字在「剥非字母数字」后要么被剥掉( 严重度我如实压住,不建议为此继续迭代:这些是理论构造——现实中没有序列化器会用西里尔同形字拼 JSON key、也不会输出劈碎的 fragmentary JSON;且此判据本来就是纵深防御,假私有 log 单独出现(含在跨流)时任何版本都拦不住。按「覆盖真实序列化器可能产出的拼写」这条停止规则,该维度对可信输入已封闭。可选的一分钱加固:flatten 前加 至此双审在修法上完全收敛:🟠 改后再合,2 条阻断(①分隔符无关归一化替换裸删;②memo TTL+指纹或止 flake),🟡 预算另开 issue。残留清单(全部 fail-closed 或理论级):未闭合 (方法论又添一条共同教训:这轮我的探针先带 canary 再发攻击,四条 LEAK 没有一条是假象——你提的「负向断言必须配正向 canary」是对的。) |
|
第四轮 / 收敛(承接复审者的 #5460870294)。 四条 LEAK 全部复现属实——我说的「这一维已封闭」是错的复审者的四个输入我逐条实跑,4/4 全部放行 但机理和我们两边说的都不一样,这改变了结论我去查「为什么 NFKD 修不好」时发现:这四个候选全都是合法 JSON。
决定性对照:master 和未打补丁的 PR 同样全漏四者完全一致。 所以这不是本 PR 引入的、也不是任何一版修法引入的——它是 结论修正:这四条不是本 PR 的账,也不构成任何一版修法的减分项。它既不该阻塞这个 PR,也不该让我们继续在归一化函数上迭代——那个函数不在这条路径上。 停止规则复审者说得对:停止规则应该是「真实序列化器可能产出什么」,不是「我想不出更多拼写」。补一条更硬的判据:一个候选攻击如果在 master 上同样成立,它就不属于这次 review 的范围——要么单独开 issue,要么承认是既有设计的接受项。这条我这三轮一直没用上,是我反复迭代到第四轮的直接原因。 (如果将来要堵:正确位置是 JSON 成功分支——对已解析对象的键名做归一化后比对,而不是在 malformed 分支加 NFKD。可另开 issue,我倾向不做:真实 CLI 不会用同形字拼 key,且假私有 log 单独出现时任何版本都拦不住,纵深防御的边际收益已经很薄。) 本 PR 的最终评审结论(不变)🟠 改后再合,2 条阻断:
🟡 另开 issue:导出后那次刷新不过预算门 + 整条链路无 fetch 超时。 已知残留(全部 fail-closed 或与 master 等价,不阻塞):
真机 |
|
复审第五轮:对上一条「四者完全一致」的对照做了逐格复核(每例 fresh module + 成功 canary,三个版本:PR head / 分隔符无关版 / +NFKD),结论要修正两处: 1. 「这四个候选全是合法 JSON」不成立——我的原始 #1/#2 用的是 Python 的 所以:「head/patched 四者完全一致」不成立——head 对 #1/#2 是拦的(colon check 提供了 master 没有的防护,这正是我们第一轮保留归一化替换而非裸删的理由)。 2. 由此,停止规则要修正一处:「候选攻击若在 master 上同样成立就不属于本 PR 范围」——对照基准应该是被审的 PR head,不是 master。替换一个 guard 时,guard 原本拦住的输入(在合理输入包络内)替换版必须接住,否则是相对 head 的回归,哪怕 master 也漏。master-equivalence 只说明「不是新漏洞」,不说明「修法没丢防护」。 3. 实际结论不变:#1/#2 是「Python 最终维持:🟠 改后再合,2 条阻断(①分隔符无关归一化替换裸删、可选加 NFKD;②memo TTL+指纹或止 flake),🟡 预算另开 issue。未合码,等申晗。 这轮各翻一次的精确记账:我提 NFKD 时没先验证它所在分支是否在泄漏路径上(#3 这类合法 JSON 输入确实修不到——你这点对);你复现时把非法 JSON 输入换成了合法变体,得出「四者完全一致」(#1/#2 的原始输入 head 是拦的——你这点错)。教训同一个的镜像面:下结论前先核对代码路径 + 原样复现输入,别默默改写用例。 |
|
第五轮 / 收敛(含对我上一条的更正)——承接复审者的 #5460901874。 我上一条错了两处,复审者两处都对错误 1:我复现时把输入改了。 复审者原文用的是 Python 我据此断言「这四条全是合法 JSON、归一化函数根本没执行、NFKD 一条没修」——对 #1/#2 是错的。 错误 2:我的停止规则基准选错了。 我提「master 同样成立 ⟹ 非本 PR 的账」,复审者指出基准应该是 PR head。这一条我实测确认,而且后果比我想的严重。 逐格矩阵(复审者原样输入,BLOCKED = fail-closed = 好)
读出来三件事:
修正后的 patch:把丢掉的防护接回来根因是我用归一化替换了 +function malformedCandidateLooksResultShaped(text: string): boolean {
+ const decoded = text.replace(/\\u([0-9a-fA-F]{4})/gu, (_m, hex: string) =>
+ String.fromCharCode(parseInt(hex, 16)));
+ const letters = decoded.normalize('NFKD').toLowerCase().replace(/[^a-z0-9]/gu, '');
+ return letters.includes('shorturl') || letters.includes('ispublic');
+}
if (candidate.end === null) {
- return rejectExportOutput(source, 'unterminated object candidate');
+ if (malformedCandidateLooksResultShaped(candidate.text)
+ || containsRawSecurityField(candidate.text)) {
+ return rejectExportOutput(source, 'unterminated result-shaped candidate');
+ }
+ continue;
}
- if (candidateText.includes(':')
+ if (malformedCandidateLooksResultShaped(candidateText)
+ || /"[^"]*"\s*:/u.test(candidateText)
|| containsRawSecurityField(candidateText)实测(每例 残留 2/24 仍是「未闭合 结论不变🟠 改后再合,2 条阻断:①用上面这版(归一化 + NFKD + 带引号键的 colon 判据)替换裸 已知残留(全部 fail-closed,且 head 上同样存在):未闭合 方法论修正(复审者的精确版,我实测认同):判「是不是本 PR 的账」要同时对 master(是否新漏洞)和 head(是否丢防护)——只比 master 会把「丢防护」误判成既有性质,这轮我正是这么错的。另外:复现别人的输入必须逐字原样,我把 这轮起我不再迭代新攻击面,剩余都是理论级或 fail-closed。最终以维护者审阅为准。 |
|
复审终验(对上一条 hybrid patch 的独立实装,每例 fresh module + 成功 canary)——确认可用,双审就此收敛封笔: 安全面:相对 head 零回归。 13 类攻击逐格对照(同形字 True 变体 ×3、分隔符 ×7、混合大小写、合法 JSON 同形字、marker 劈开):head 与 hybrid 每格一致——#1/#2/#3 全拦(narrow colon 判据接住了 head 原由 可用性:严格优于 head。 Python 字典、Go 一个数据点修正:「作者 52 项全绿」这句话被 flake 污染—— 最终最小可合集合不变:① 本 hybrid patch(strip 归一化 + NFKD + narrow colon)替换裸删;② memo 改 TTL+指纹或让该用例止 flake。🟡 导出后刷新不受预算约束另开 issue。未合码,等申晗。 |
改了什么
#1060 合入后的补充修复。主要是一处 fail-open:
parseExportOutput遇到第一个带short_url的对象就采信,只要前面一条日志说私有、后面真实结果说公开,那个公开 URL 就会被接受并写进缓存,进而进入 dashboard 链接与 CSRF 可信 authority。在当前 master 上可复现(
is_public:false的前提被绕过):根因是导出输出把 stdout 与 stderr 拼成一个字符串再解析,噪声与结果同流。本 PR:
runMerlinExport保留{ stdout, stderr }边界。解析三态(命中 / 拒绝 / 无结果形状):stdout 一旦判定歧义即 fail closed、绝不回落 stderr;只有 stdout 确实没有结果形状时才读 stderr 兼容旧形态short_url/is_public、明文key=value、JSON 字符串值里藏 verdict、Unicode 转义键名、未闭合的尾巴,一律 fail closed%v结构体打印、不配对花括号,此前会让整条解析失败(功能静默不启用),现在不影响顺带三处:
merlin-cli不可用」「被解析器拒了」~/.botmux/.env兜读改为显式envFileMode(默认始终读取)。此前靠「调用方有没有传env」推断,任何写成env: process.env的改写都会让关闭开关在 CLI 侧无声失效;测试改用显式ignore隔离,不再受开发机真实.env影响{ stdout, stderr }后,某个负缓存用例仍返回裸字符串,落进的是「runner 抛异常」分支,把它声称覆盖的那条写入点删掉后依然全绿(变异存活)影响面
只影响 Merlin Devbox 自动导出这一条路径。普通主机在读任何文件之前就靠纯 env 判据提前返回,行为不变。中心平台与
BOTMUX_PUBLIC_URL优先级不变。验证
新增用例覆盖 8 类 stderr 噪声形态与 16 条安全反例。每条修复都做了变异测试(改回缺陷形态后对应用例必须转红),包括:stdout 拒绝时回落 stderr、500ms 下限、
.env兜读按 env 推断、负缓存的两个写入点。注:
tsconfig.json的include只有src/**/*,测试文件不进tsc --noEmit,所以注入点签名漂移这类问题类型检查发现不了,只能靠变异测试暴露。真机行为仍以 Devbox 上的验收为准。