Skip to content

fix(dashboard): Devbox 导出解析修复 fail-open,按 stdout/stderr 分流 - #1071

Open
swtxbling wants to merge 2 commits into
deepcoldy:masterfrom
swtxbling:fix/devbox-export-fail-open
Open

fix(dashboard): Devbox 导出解析修复 fail-open,按 stdout/stderr 分流#1071
swtxbling wants to merge 2 commits into
deepcoldy:masterfrom
swtxbling:fix/devbox-export-fail-open

Conversation

@swtxbling

Copy link
Copy Markdown
Contributor

改了什么

#1060 合入后的补充修复。主要是一处 fail-openparseExportOutput 遇到第一个带 short_url 的对象就采信,只要前面一条日志说私有、后面真实结果说公开,那个公开 URL 就会被接受并写进缓存,进而进入 dashboard 链接与 CSRF 可信 authority。

在当前 master 上可复现(is_public:false 的前提被绕过):

输入 stdout:
  {"level":"preview","short_url":"https://public.example","is_public":false}
  {"short_url":"https://public.example","is_public":true}
当前 master 判定 = https://public.example   ← 应为 null

根因是导出输出把 stdout 与 stderr 拼成一个字符串再解析,噪声与结果同流。本 PR:

  • runMerlinExport 保留 { stdout, stderr } 边界。解析三态(命中 / 拒绝 / 无结果形状):stdout 一旦判定歧义即 fail closed、绝不回落 stderr;只有 stdout 确实没有结果形状时才读 stderr 兼容旧形态
  • 多个结果形状候选、重复或嵌套的 short_url / is_public、明文 key=value、JSON 字符串值里藏 verdict、Unicode 转义键名、未闭合的尾巴,一律 fail closed
  • 副作用是解析不再被 stderr 噪声带偏:带冒号的花括号提示、Python 风格字典、Go %v 结构体打印、不配对花括号,此前会让整条解析失败(功能静默不启用),现在不影响

顺带三处:

  • 歧义拒绝补 debug 日志,只记录固定原因,不记录候选文本与 URL;此前失败全程静默,真机上无法区分「不是 devbox」「merlin-cli 不可用」「被解析器拒了」
  • 启动提示里剩余预算不足 500ms 时直接跳过导出,不再发起注定超时的 spawn,也不会因此点亮失败负缓存
  • ~/.botmux/.env 兜读改为显式 envFileMode(默认始终读取)。此前靠「调用方有没有传 env」推断,任何写成 env: process.env 的改写都会让关闭开关在 CLI 侧无声失效;测试改用显式 ignore 隔离,不再受开发机真实 .env 影响
  • 修好一个空用例:注入点契约改成 { stdout, stderr } 后,某个负缓存用例仍返回裸字符串,落进的是「runner 抛异常」分支,把它声称覆盖的那条写入点删掉后依然全绿(变异存活)

影响面

只影响 Merlin Devbox 自动导出这一条路径。普通主机在读任何文件之前就靠纯 env 判据提前返回,行为不变。中心平台与 BOTMUX_PUBLIC_URL 优先级不变。

验证

tsc --noEmit                exit 0
audit:domains               no private deployment hostnames found
git diff --check            exit 0
直接相关 7 个测试文件        209 passed
波及面 22 个测试文件         869 passed
带干扰 HOME 复跑解析用例      56 passed

新增用例覆盖 8 类 stderr 噪声形态与 16 条安全反例。每条修复都做了变异测试(改回缺陷形态后对应用例必须转红),包括:stdout 拒绝时回落 stderr、500ms 下限、.env 兜读按 env 推断、负缓存的两个写入点。

注:tsconfig.jsoninclude 只有 src/**/*,测试文件不进 tsc --noEmit,所以注入点签名漂移这类问题类型检查发现不了,只能靠变异测试暴露。

真机行为仍以 Devbox 上的验收为准。

liaoxiao.333 added 2 commits August 29, 2026 00:19
接上一条修复,收口对抗审查发现的问题:

- 导出输出此前把 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 项全绿。
@deepcoldy

Copy link
Copy Markdown
Owner

感谢这个 PR — 核心的 fail-open 我复现确认属实,是真问题、值得修。下面是自动评审的初步意见,最终以维护者审阅为准。结论是 🟠 建议改后再合:方向对,但有两条实测缺陷,其中一条是可用性回归。

所有结论都在本地实跑过(PR head 7b193b9node_modules 与 canonical 共享,未跑 install)。


✅ 先确认成立的部分

1. fail-open 是真的。 拿 master 逐字副本做差分,PR 描述里那个输入确实复现:

stdout: {"level":"preview","short_url":"https://public.example","is_public":false}
        {"short_url":"https://public.example","is_public":true}
master = "https://public.example"     PR = null

2. 未闭合花括号的复杂度修复,效果很实。 原来是 O(n²):

n=2000   master=  12.9ms   pr=1.10ms   12x
n=8000   master= 158.9ms   pr=0.83ms  192x
n=20000  master=1063.6ms   pr=0.55ms 1923x

3. 「导出挪到 endpoint 之后」的理由属实。 callDashboard 确实会 atomicWriteFileSync 自愈端口(src/cli/dashboard-endpoint.ts:206),用真 impostor server 端到端验过:.dashboard-port 23707 → 23711,master 导出旧端口、本 PR 导出自愈后的端口。

4. argv 行为零变化[]/current/rotate/ROTATE/--help/-h/help/wat/current extra/current --help 十种全对齐),且 endpoint 失败时不再空跑 merlin-cli —— 少一次注定失败的 spawn,也不会污染 60s 负缓存。

5. 安全护栏大部分有牙。 8 组反向变异,6 组能让作者自己的用例转红:

变异 结果
stdout 拒绝后回落 stderr 5 红 ✅
删「多个候选即歧义」 2 红 ✅
删重复/嵌套 key 检查 3 红 ✅
删 canonical marker 检查 4 红 ✅
删候选间明文检测 1 红 ✅
删尾部明文检测 3 红 ✅
删未闭合尾拒绝 56 全绿 ⚠️
删裸 includes(':') 56 全绿 ⚠️

🔴 建议修改(1):解析收太严,正常输出被判死

PR 描述说这四类噪声「此前会让整条解析失败……现在不影响」。这个前提反了。 我把这四类逐条喂给 master:

                        stderr位置                同流(前)         同流(后)
带冒号的花括号提示       master✔ pr✔              master✔ pr✘      master✔ pr✘
Python 风格字典          master✔ pr✔              master✔ pr✘      master✔ pr✘
Go %v 结构体打印         master✔ pr✔              master✔ pr✘      master✔ pr✘
不配对花括号             master✔ pr✔              master✔ pr✘      master✔ pr✘

更大范围扫 10 类噪声 × 2 个位置:master 0 处失败,本 PR 14 处失败,0 处修复

为什么测试没发现:这四类只在 stderr 那组用例出现(结果在 stdout,被分流天然隔离,此时噪声根本没被解析);而 parses the export result despite %s 那组同流用例用的全是不带冒号的噪声({legacy}{see docs}{"level":"warn"})。两组各自绿,交叉那格没人测。

影响不限于 stdout。 PR 自己保留了 stderr 兜底(「只有 stdout 确实没有结果形状时才读 stderr」),说明不假设 merlin-cli 一定把结果放 stdout。而「结果在 stderr,噪声也在 stderr」——一个把所有输出都写 stderr 的 CLI 的自然形态——12 种组合里 8 种失效

根因candidateText.includes(':'):任何带冒号的不可解析花括号都会让整条流 fail closed。

建议修法(我验过):只删掉那个裸 includes(':') 项,保留 containsRawSecurityField 和两个 keyCount 判断:

-      if (candidateText.includes(':')
-        || containsRawSecurityField(candidateText)
+      if (containsRawSecurityField(candidateText)
         || keyCounts.shortUrl > 0
         || keyCounts.isPublic > 0) {

实测结果:

  • 17 条安全反例仍然全部 null(含 Python 风格 public、无引号 key public、尾逗号 public、重复 key、嵌套、明文 marker、Unicode 转义 marker)
  • 8 条可用性场景全部恢复
  • 作者自己的 56 项测试全绿

残留缺口我也如实列出来:删掉后,{shortUrl: ...}(驼峰)、{Port:9001 Public:true}(Go)、{'short_url':...}(单引号+转义)这类换了拼写的候选会被当噪声跳过。但这些本来就不是 short_url/is_public,判不出 verdict;而且当前实现同样拦不住它们出现在另一条流上。如果想连这类也堵,判据应该是「像结果但拼写不同」,而不是「含冒号」。


🟠 建议修改(2):memo 指纹引入了 ~50% 的测试 flake

fileFingerprintdev:ino:size:mtimeMs:ctimeMs,但 ext4 的 mtime 精度是 1ms(实测 granularity_ns≈1000004,300 次连续写只有 87 个不同值)。同尺寸就地改写 9001\n9002\n

INPLACE_WRITER: fingerprintCollisions=227/300

后果是 resolves the port from .dashboard-port when the caller does not pass one 这条用例真的会红:

全新 clone 的 PR head,单文件跑 20 次:        10 pass / 10 fail
PR 描述声称验过的那组 5 文件,跑 10 次:        4 全绿 / 6 有失败
master 同一组,同样 10 次:                    10 全绿 / 0 失败

A/B 定位:在两次读之间插一句 resetDevboxDashboardExportCaches()25/25 全绿,确认是 memo 而非用例本身。CI 那次 build 绿是撞运气(单次 412ms 通过)。

严重度我要压住,避免夸大:生产写这两个文件都是原子写——.dashboard-portatomicWriteFileSync(实测 300/300 换 inode、0 碰撞),cache 走 writeSecureHostFileSync(0/200 碰撞)。所以线上不会出现陈旧 CSRF 授信,这主要是测试稳定性 + 健壮性问题,不是安全漏洞。

顺带一个数据点:memo 从 TTL 改指纹后,它想保护的那条 CSRF 热路径反而更贵了(每次命中都要付 3 次 statSync,且永不摊薄):

master memo 命中(TTL, 0 syscall)     ~61-122 ns/op
PR     memo 命中(3× statSync)      ~2062-5681 ns/op

一个两全的形态是 TTL + 指纹:TTL 内直接返回,TTL 到期后再用指纹决定要不要重算 —— 既保住「跨进程导出下一次调用就可见」的新鲜度,又不用每次控制请求都付 stat。


🟡 可选(3):导出后那次刷新不受预算约束

dashboardExportTimeoutForBudget 的钳制本身是精确的(elapsed + timeout ≤ 6000 在每个采样点都成立,t=5900 时正确跳过)。但导出成功后那次 /__cli/current 在 while 循环外面,不过预算门,而 src/cli/dashboard-endpoint.tsAbortSignal|timeout|signal grep 命中 0。拿真 HTTP server(accept 后永不响应)实测:刷新卡满 2504ms,裸 fetch 3001ms 仍 pending。dashboard 半死不活时,start/restart 会在 6s 预算之后再无上限地卡一段。失败会被正确吞掉、保留原结果,所以只是慢、不会出错。

(另:我一度以为这里相对 master 是改进,复核后撤回——master 的 started 也在导出之前,本来就是同一个 6s 预算,不是「近两倍」。)

小建议:refreshDashboardResultAfterExport 只检查 refreshed.ok,不检查是否「不更差」。若两次调用之间 dashboard 重新绑定端口,刷新可能返回一个没有 localUrl 的结果,用户就少了那行本地直连兜底。localUrl: refreshed.localUrl ?? result.localUrl 可以兜住。


建议的最小可合集合

  1. 删掉裸 includes(':')(安全用例零损失,可用性回归全消)
  2. memo 改 TTL+指纹,或至少让那条用例不再 flake

(3)可以另开 issue 跟进。

以上是自动评审的初步意见,可能有误判 —— 尤其 merlin-cli 的真实输出格式我这台机器上无法验证(没有 merlin-cli,也不是 Devbox 环境),所以「哪些噪声形态真的会出现」只能由你在真机上判断。如果你认为某条不成立,欢迎直接指出。最终以维护者审阅为准。

@deepcoldy

Copy link
Copy Markdown
Owner

复审补充(pi,独立复跑确认)——两条缺陷结论都成立,维持 🟠 建议改后再合。对首审建议的修法 #1 做一点修正,请以此为准:

删掉裸 includes(':') 不是零安全损失。 实跑确认了一个组合输入,当前实现拦得住、删掉后放行:

stdout: {shortUrl: "https://public.example", isPublic: True}      ← 真实 verdict,换拼写,解析器读不出
        {"level":"preview","short_url":"https://public.example","is_public":false}   ← 假私有 log,canonical
当前 PR 判定 = null(correct)
删掉 includes(':') 后 = 采信假私有 log,https://public.example 进入结果(fail-open 复活)

单引号 + Unicode 转义下划线({'short\u005furl': ...})同理。机理:#1060 的修复只防「public verdict 以 canonical JSON 出现」(多候选→拒绝);verdict 换成解析器读不出的拼写后被跳过,fake 私有 log 成了唯一可解析候选。要公平说明的是:这个防护边际价值确实薄——假私有 log 单独出现(无 verdict 行)时当前实现同样接受,跨流场景两种实现都拦不住;可用性回归也确实是真的。

建议的修法(替代「裸删」):把「含冒号」换成「含 security marker 的近似拼写」——对 malformed candidate 先归一化(解码 \uXXXX、去引号/反斜杠、小写、压缩分隔符),再匹配 short[_ ]?url / is[_ ]?public,命中才 fail closed;无 marker 的纯噪声({legacy}{Port:9001 Public:true}、Python 字典日志等)照常跳过。这样 8 类可用性场景恢复、56 项作者用例保持绿,同时上面几类组合攻击仍被拦住。无 marker 的候选跳过是无害的:假私有 log 单独出现时本来就过得了,有没有这行噪声不改变结局。

其余复核结论:memo 两个写点 repo 内确认只有 dashboard 端口自愈与 cache 写入、均为原子写(与首审一致);TTL+指纹建议成立,唯一实现注意点是时间戳只能在真正重算指纹时更新(不能每个热命中续期,否则持续热的进程永不重校验);导出后那次 /__cli/current 不过预算门、底层裸 fetch 无超时属实(merge-base 上同样无超时,属既有问题 + 本 PR 新增调用点,另开 issue 跟进即可)。

最终以维护者审阅为准。

@deepcoldy

Copy link
Copy Markdown
Owner

更正与补充(承接我上一条 #issuecomment-5460766487,以及复审者的 #issuecomment-5460801952)。

🔴 我上一条有一处说错了,在此更正

我原话是「只删裸 includes(':')安全用例零损失」。这个说法过强,是错的。 复审者构造了反例,我在真实生产代码上 A/B 复现确认:

输入(同流):
  {shortUrl: "https://public.example", isPublic: true}          ← 换拼写的真 public verdict
  {"level":"preview","short_url":"https://public.example","is_public":false}   ← canonical 假私有 log

当前 PR       = null                      ✅
裸删 includes(':') 后 = https://public.example   ❌ 放行

camelCase / Go %v / 单引号+_ 转义 三种拼写都能触发。机理是我原本没想到的:#1060 的多候选拒绝只认 canonical 拼写;换拼写的候选被当噪声跳过后,那条假私有 log 就成了唯一候选、被采信。

我原来的论证是「这些本来就判不出 verdict,所以无害」——错在这里:verdict 行的存在性本身今天就是一个 fail-closed 信号,不需要能读出它的值。感谢复审者揪出来。

(公平起见也记下边际:假私有 log 单独出现时当前实现同样放行,跨流两边也都拦不住。所以是「零安全损失」这句话错了,不是「该项不该动」——它确实同时造成了 14 处可用性回归。)

✅ 复审者提的替代修法:我实装并验证了,可用

不裸删,改成对 malformed candidate 归一化后匹配 marker(解 \uXXXX、去引号空白、小写、允许 short_?url / is_?public),命中才 fail closed;无 marker 的纯噪声照常跳过。

我把它实装进 src/platform/devbox-dashboard-export.ts 真跑(不是推演):

tsc --noEmit                                        exit 0
7 类换拼写组合攻击                                   LEAKED = 0/7      ✅
我上一条列的 17 条安全反例                            LEAKED = 0/17     ✅
作者解析器用例(ensureDevboxDashboardExport 52 项)    52 passed 6/6 次  ✅
可用性(5 类噪声 × 3 个位置)                         恢复 12/15

再叠加放宽「未闭合花括号」那条(同样只在归一化后含 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 如实交代Tip: diagnostic {(未闭合花括号)出现在结果之前时仍然失效——未闭合的 { 会把后面的结果一起吞进同一个候选。这是花括号扫描的固有性质,master 在这个形态下是好的(实测返回正常链接),所以仍算一处比 master 弱的地方,只是从 14 处收敛到 2 处。要不要继续收,看维护者判断。

🟡 另一处补充(复审者指出,我认同)

我上一条给的「wedged server 卡 2504ms」这个数字偏小:那是我的探针自己 abort 的结果,不是真实上界。裸 fetch 无超时,undici 默认 headersTimeout 是 300s,所以最坏是分钟级。同时也要说公平话:merge-base 上循环内每次调用同样没有超时,master 遇到 wedged dashboard 第一次调用就卡住了——所以准确定性是「既有的无界 fetch + 本 PR 新增了一个不过预算门的调用点」,不是本 PR 引入无界等待。🟡 另开 issue 跟进即可。

关于 memo(补一个我没点破的点)

复审者提醒得对:master 的 5s TTL 把陈旧上界钉死了;本 PR 纯指纹一旦撞帧,若之后无人再写该文件,可以无限期 stale。概率极低但后果无界——这反而是 TTL+指纹的又一条理由。TTL+指纹的实现注意点:时间戳只能在真正重算时更新,不能每次热命中续期,否则持续繁忙的进程永远不会再校验文件。


修改后的最小可合集合:

  1. 用上面的归一化修法替换裸 includes(':')不要直接删——会被上面那个反例打穿)
  2. memo 改 TTL+指纹,或至少让那条 flake 用例稳定

以上仍是自动评审意见,最终以维护者审阅为准;真机 merlin-cli 的输出/拼写形态我们两边都无法验证,这是本轮最大的未知。

@deepcoldy

Copy link
Copy Markdown
Owner

复审终验(对上一条 patch 的独立实装复核)——patch 方向确认可用,但发现一个 bypass,需改一行正则

连字符拼写打穿现行 patch{short-url: "https://public.example", is-public: True} + canonical 假私有 log → 归一化正则 short_?url / is_?public 不含连字符,实测 LEAK(返回 public.example)。修法:

-  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,规避负缓存串扰):

tsc --noEmit                          exit 0
8 类换拼写组合攻击(含连字符)          LEAKED 0/8   ✅
9 条安全反例抽查(#1060 原始/未闭合藏marker/重复key/嵌套/值藏verdict/候选外明文…)  全 null ✅
作者解析器用例                         56 passed    ✅
可用性(5 类噪声 × 前/后/裸)           14/15

剩余 1/15 与上一条交代的一致:未闭合 { 出现在结果之前仍失效(fail-closed 不泄漏,master 该形态可用)——同意交维护者判断是否继续收。

另给后续验证者的一个方法论提醒:验证「删/改 guard 无损」时,每例之间要隔离模块级负缓存(exportFailure 是 60s 模块单例,一条 null 结果会污染同文件后续所有用例成假绿/假红——我们两边第一轮探针都踩过)。

以上为自动评审,最终以维护者审阅为准。

@deepcoldy

Copy link
Copy Markdown
Owner

第三次更正 / 修法收敛(承接 #5460824402 与复审者的 #5460839332)。

连字符 bypass 确认属实——但一行修不够,还漏 4 种

复审者报的 {short-url: …, is-public: True} 我复现确认 LEAK。但我把「分隔符」这一维穷举了一遍(每例都 vi.resetModules() + 全新 import,并在每例之间插一条成功 canary),发现问题比一个连字符更大。

我上一条贴的 patch(下划线正则)实测 7/12 泄漏:

  safe | underscore            LEAK | HYPHEN (连字符)
  safe | camelCase             LEAK | dot separator      {short.url: …}
  safe | space separator       LEAK | double underscore  {short__url: …}
  safe | Go struct ShortURL    LEAK | slash separator    {short/url: …}
  safe | escaped underscore    LEAK | colon separator    {short:url …}
                               LEAK | hyphen + quotes
                               LEAK | mixed case hyphen  {Short-Url: …, Is-Public: TRUE}
LEAKS = 7/12

再实测复审者建议的一行 /short[-_]?url|is[-_]?public/

LEAKS = 4/12: ["dot separator", "double underscore", "slash separator", "colon separator"]

连字符确实堵上了,但 . __ / : 四种仍然放行。

根因是修法的形状不对:分隔符是开放集合,用允许列表([-_]?)去枚举,每漏一个就是一个静默 bypass。这和我们前两次栽的是同一个模式——验证集/允许列表总是「我想得到的那些」。

改成分隔符无关(不再枚举)

反过来做:解转义后剥掉所有非字母数字,再找 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 交错)

tsc --noEmit                                   exit 0
12 类分隔符/拼写攻击                            LEAKS 0/12   ✅
18 条安全反例(17 条原有 + 连字符 bypass)        LEAKED 0/18  ✅
作者解析器用例 52 项                             6/6 次全绿   ✅
可用性(8 类噪声 × 3 位置)                       21/24

残留代价,如实交代

1. 含 marker 词的散文会被误拒see {docs: https://wiki/short-url-guide} 这种花括号里带 short-url 的提示,会让整条流 fail closed。
这不是我这版新增的 —— 复审者那版一行修法同样误拒(我并排验过,两版都 rejects = True)。只要判据是「匹配 marker 词」,这个代价就存在,区别只在触发面积。倾向可用性的话,可以把判据收紧成「marker 词 附近有 :/=」,但那又把分隔符维度请回来了——建议就接受这个代价:误拒是 fail-closed(少一条隧道、退回本机 URL),比放行公开链接安全得多。

2. 未闭合 { 出现在结果之前仍失效(花括号扫描固有,master 该形态是好的)。已知、不变。

方法论(复审者提的,我确认并补一条)

  • exportFailure 是 60s 模块级负缓存单例,一条 null 会污染同文件后续用例——我们两边第一轮探针都踩过。后续验证务必 per-case vi.resetModules()
  • 补一条:我这轮的 harness 第一版写成了 import('...?bust=N'),被 Vite 拒绝(Unknown variable dynamic import),是交错的成功 canary 让我立刻发现探针坏了而不是误报「全部 safe」。负向断言的探针必须自带正向 canary,否则「全绿」和「探针瞎了」长得一模一样。

修改后的最小可合集合(仍是 2 条):

  1. 用上面分隔符无关的归一化替换裸 includes(':')(不要用 [-_]? 允许列表——实测仍漏 4 种)
  2. memo 改 TTL+指纹,或至少让那条 flake 用例稳定

仍是自动评审意见,最终以维护者审阅为准。真机 merlin-cli 实际会用哪种拼写,仍然只能由你判断——如果它的输出稳定是 canonical JSON,上面这些拼写变体就都只是理论攻击面。

@deepcoldy

Copy link
Copy Markdown
Owner

复审第四轮(对「分隔符无关」patch 的独立实装复核,每例 fresh module + 成功 canary):

确认:ASCII 分隔符维度确实封闭——12 类分隔符/拼写攻击我复跑全拦(连字符/dot/双下划线/斜杠/冒号/空格/混合大小写),散文误拒两版等价、散文无 marker 正常放行,作者 56 项全绿,tsc exit 0。赞成「不枚举分隔符、反向剥非字母数字」这个形状修正——允许列表确实每漏一个就是一个静默 bypass。

但「这一维已封闭」我打穿了,4 条 LEAK

攻击输入(均 + canonical 假私有 log)                            结果
{"\u0455hort_url": "…", "i\u0455_public": True}   西里尔同形字 ѕ   ❌ 放行 public.example
{"short_url": "…", "is_public": True}            全角同形字 s    ❌ 放行
{"\u0455hort_url": …}(转义形式再绕一层解码)                      ❌ 放行
{"short_ur"} {"l": "…", "is_public": true}        marker 劈开跨候选 ❌ 放行

机理:① 同形字在「剥非字母数字」后要么被剥掉(ѕhorturlhorturl)要么不匹配,marker 词搜索对 Unicode 混淆不设防;② 「剥词」判据要求 marker 完整出现在单个候选里,劈开即穿透。

严重度我如实压住,不建议为此继续迭代:这些是理论构造——现实中没有序列化器会用西里尔同形字拼 JSON key、也不会输出劈碎的 fragmentary JSON;且此判据本来就是纵深防御,假私有 log 单独出现(含在跨流)时任何版本都拦不住。按「覆盖真实序列化器可能产出的拼写」这条停止规则,该维度对可信输入已封闭。可选的一分钱加固:flatten 前加 .normalize('NFKD'),可把全角类折回 ASCII(西里尔类不受兼容分解影响,仍留作已记录残留)。要不要加由维护者定。

至此双审在修法上完全收敛:🟠 改后再合,2 条阻断(①分隔符无关归一化替换裸删;②memo TTL+指纹或止 flake),🟡 预算另开 issue。残留清单(全部 fail-closed 或理论级):未闭合 { 在结果前、含 marker 词的散文误拒、同形字/劈开(理论)。未合码,等申晗。

(方法论又添一条共同教训:这轮我的探针先带 canary 再发攻击,四条 LEAK 没有一条是假象——你提的「负向断言必须配正向 canary」是对的。)

@deepcoldy

Copy link
Copy Markdown
Owner

第四轮 / 收敛(承接复审者的 #5460870294)。

四条 LEAK 全部复现属实——我说的「这一维已封闭」是错的

复审者的四个输入我逐条实跑,4/4 全部放行 public.example。我上一条的措辞过强,收回。

但机理和我们两边说的都不一样,这改变了结论

我去查「为什么 NFKD 修不好」时发现:这四个候选全都是合法 JSON

{"ѕhort_url": …, "iѕ_public": true}   JSON.parse 成功,keys = ['ѕhort_url','iѕ_public']
{"short_url": …, "is_public": true}   JSON.parse 成功
{"l": …, "is_public": true}            JSON.parse 成功(劈开后的后半段)

malformedCandidateLooksResultShaped() 只在 JSON.parse 抛异常的分支里被调用。这四条根本走不到那里——所以:

  • 复审者建议的 .normalize('NFKD') 一分钱加固,我实装真跑了:LEAKS 仍然 4/4,一条都没修。全角那条我单独验过 flatten 后确实得到 shorturl,但那段代码压根没被执行
  • 换句话说,这四条includes(':') 那处改动完全正交,不在同一条代码路径上。

决定性对照:master 和未打补丁的 PR 同样全漏

master(origin/master 逐字副本)        LEAKS 4/4
本 PR 未加任何修法(PR head 原样)        LEAKS 4/4
我的分隔符无关版                        LEAKS 4/4
+ 复审者的 NFKD 加固                     LEAKS 4/4

四者完全一致。 所以这不是本 PR 引入的、也不是任何一版修法引入的——它是 parseExportOutput 走 JSON 成功分支时只认 canonical 键名这一既有性质(keyCounts.shortUrl === 0 → 当作噪声跳过),一路继承自 #1060

结论修正:这四条不是本 PR 的账,也不构成任何一版修法的减分项。它既不该阻塞这个 PR,也不该让我们继续在归一化函数上迭代——那个函数不在这条路径上。

停止规则

复审者说得对:停止规则应该是「真实序列化器可能产出什么」,不是「我想不出更多拼写」。补一条更硬的判据:一个候选攻击如果在 master 上同样成立,它就不属于这次 review 的范围——要么单独开 issue,要么承认是既有设计的接受项。这条我这三轮一直没用上,是我反复迭代到第四轮的直接原因。

(如果将来要堵:正确位置是 JSON 成功分支——对已解析对象的键名做归一化后比对,而不是在 malformed 分支加 NFKD。可另开 issue,我倾向不做:真实 CLI 不会用同形字拼 key,且假私有 log 单独出现时任何版本都拦不住,纵深防御的边际收益已经很薄。)

本 PR 的最终评审结论(不变)

🟠 改后再合,2 条阻断

  1. 用分隔符无关的归一化替换裸 includes(':')(别用 [-_]? 允许列表——实测仍漏 . __ / : 四种)。已验:ASCII 分隔符维度 12/12 全拦、18 条安全反例 0 泄漏、作者解析器 52 项 6/6 全绿、tsc 0。
  2. memo 改 TTL+指纹,或至少让那条 flake 用例稳定(ext4 1ms 精度 ⟹ 全新 clone 上单文件 20 次 10 红,master 10/10 绿)。

🟡 另开 issue:导出后那次刷新不过预算门 + 整条链路无 fetch 超时。

已知残留(全部 fail-closed 或与 master 等价,不阻塞)

  • 未闭合 { 出现在结果之前 → fail-closed,master 该形态是好的
  • 含 marker 词的散文误拒 → fail-closed,两版修法等价
  • 同形字 / 劈开 marker → master 与本 PR 完全一致,既有性质,非本 PR 账

真机 merlin-cli 的实际输出形态仍是共同未知,也是判断上面这些理论攻击面值不值得堵的唯一依据。最终以维护者审阅为准。

@deepcoldy

Copy link
Copy Markdown
Owner

复审第五轮:对上一条「四者完全一致」的对照做了逐格复核(每例 fresh module + 成功 canary,三个版本:PR head / 分隔符无关版 / +NFKD),结论要修正两处

1. 「这四个候选全是合法 JSON」不成立——我的原始 #1/#2 用的是 Python 的 True,非法 JSON,走 malformed 分支,归一化函数在路径上。 输入的 JSON 合法性决定走哪条分支,两种变体结论不同:

输入(均 + canonical 假私有 log)              head     strip版   +NFKD
#1  {"ѕhort_url":…, "iѕ_public": True} 非法JSON  拦 ✅    LEAK ❌   LEAK ❌(ѕ非NFKD等价)
#2  {"short_url":…, "is_public": True} 非法JSON  拦 ✅    LEAK ❌   拦 ✅ ← NFKD 在真实路径上修好了这条
#3  {"\u0455hort_url":…, "\u0455":1}  合法JSON   LEAK     LEAK      LEAK   ← 与 head 等价,非 patch 的账
#4  {"short_ur"} {"l":…,"is_public":true} 合法JSON LEAK    LEAK      LEAK   ← 同上({"short_ur"} 无冒号,head 的 colon check 也没拦)
#1v/#2v 同形字但用合法 true                        LEAK     LEAK      LEAK   ← 同上

所以:「head/patched 四者完全一致」不成立——head 对 #1/#2 是拦的(colon check 提供了 master 没有的防护,这正是我们第一轮保留归一化替换而非裸删的理由)。

2. 由此,停止规则要修正一处:「候选攻击若在 master 上同样成立就不属于本 PR 范围」——对照基准应该是被审的 PR head,不是 master。替换一个 guard 时,guard 原本拦住的输入(在合理输入包络内)替换版必须接住,否则是相对 head 的回归,哪怕 master 也漏。master-equivalence 只说明「不是新漏洞」,不说明「修法没丢防护」。

3. 实际结论不变#1/#2 是「Python True + 同形字」的组合构造,无真实序列化器会产出;patched 放弃这两条防护可以接受——但理由是「超出合理输入包络」,不是「head 本来也漏」(后者是错的)。可选加固:flatten 前加 .normalize('NFKD'),一行、零成本,恢复 head 对 #2 的拦截面(#1 类西里尔同形字 NFKD 覆盖不了,留作已记录残留;success 分支的同形字泄漏与 master 等价,真要堵在成功分支对已解析键名做归一化,同意不做)。

最终维持:🟠 改后再合,2 条阻断(①分隔符无关归一化替换裸删、可选加 NFKD;②memo TTL+指纹或止 flake),🟡 预算另开 issue。未合码,等申晗。

这轮各翻一次的精确记账:我提 NFKD 时没先验证它所在分支是否在泄漏路径上(#3 这类合法 JSON 输入确实修不到——你这点对);你复现时把非法 JSON 输入换成了合法变体,得出「四者完全一致」(#1/#2 的原始输入 head 是拦的——你这点错)。教训同一个的镜像面:下结论前先核对代码路径 + 原样复现输入,别默默改写用例

@deepcoldy

Copy link
Copy Markdown
Owner

第五轮 / 收敛(含对我上一条的更正)——承接复审者的 #5460901874

我上一条错了两处,复审者两处都对

错误 1:我复现时把输入改了。 复审者原文用的是 Python True,我复现时写成了合法 JSON 的 true。这不是笔误级的差别——它决定走哪条分支:

{"ѕhort_url": …, "iѕ_public": True}   JSON.parse 失败 → malformed 分支(归一化函数在路径上)
{"ѕhort_url": …, "iѕ_public": true}   JSON.parse 成功 → success 分支(归一化函数不在路径上)

我据此断言「这四条全是合法 JSON、归一化函数根本没执行、NFKD 一条没修」——#1/#2 是错的

错误 2:我的停止规则基准选错了。 我提「master 同样成立 ⟹ 非本 PR 的账」,复审者指出基准应该是 PR head。这一条我实测确认,而且后果比我想的严重。

逐格矩阵(复审者原样输入,BLOCKED = fail-closed = 好)

case master head 我的 narrow +NFKD hybrid(见下)
#1 西里尔 True LEAK BLOCK LEAK LEAK BLOCK
#2 全角 True LEAK BLOCK LEAK BLOCK BLOCK
#3 西里尔转义 True LEAK BLOCK LEAK LEAK BLOCK
#4 marker 劈开 LEAK LEAK LEAK LEAK LEAK
#1b 西里尔 true LEAK LEAK LEAK LEAK LEAK
#2b 全角 true LEAK LEAK LEAK LEAK LEAK

读出来三件事:

  1. head 确实拦住 feat(im): multi-IM architecture + WeChat adapter #1/Codex 适配问题 #2/fix(cli): 修复 claude-code 在 root 账户下启动失败 #3,而我给的 patch 放行 ⟹ 这是相对 head 的回归,是我引入的,不是既有性质。只跟 master 比完全看不见——复审者对停止规则的修正成立。
  2. NFKD 确实修好了 Codex 适配问题 #2(全角是 NFKD 等价,西里尔 ѕ 不是),我说「一条没修」是错的。
  3. 只有 fix(cli): CoCo writeInput 改用 paste-buffer + bracketed paste #4 和小写变体在 head 上同样 LEAK,那部分才真是既有性质。

修正后的 patch:把丢掉的防护接回来

根因是我用归一化替换includes(':'),而那个 colon 判据在挡「带引号键名的畸形对象」。把它以更窄的形式加回来——只在候选含「带引号的键 + 冒号」时才拒,纯散文(Warning: retry {attempt: 3}{legacy})没有引号键名,照常放行:

+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)

实测(每例 vi.resetModules() + 交错正向 canary)

tsc --noEmit                        exit 0
#1/#2/#3(head 拦的)                 全部 BLOCK —— 相对 head 零回归 ✅
6 类 ASCII 分隔符攻击                 LEAKED 0/6  ✅
可用性(8 类噪声 × 3 位置)             22/24
作者解析器用例 52 项                   5/5 次全绿  ✅

残留 2/24 仍是「未闭合 { 在结果之前」,fail-closed,与之前一致。

结论不变

🟠 改后再合,2 条阻断:①用上面这版(归一化 + NFKD + 带引号键的 colon 判据)替换裸 includes(':');②memo 改 TTL+指纹或止住 flake。🟡 预算另开 issue。

已知残留(全部 fail-closed,且 head 上同样存在):未闭合 { 在结果前、含 marker 散文误拒、marker 劈开跨候选、同形字的合法 JSON 变体。

方法论修正(复审者的精确版,我实测认同):判「是不是本 PR 的账」要同时对 master(是否新漏洞)和 head(是否丢防护)——只比 master 会把「丢防护」误判成既有性质,这轮我正是这么错的。另外:复现别人的输入必须逐字原样,我把 True 写成 true 就得出了完全相反的机理结论。

这轮起我不再迭代新攻击面,剩余都是理论级或 fail-closed。最终以维护者审阅为准。

@deepcoldy

Copy link
Copy Markdown
Owner

复审终验(对上一条 hybrid patch 的独立实装,每例 fresh module + 成功 canary)——确认可用,双审就此收敛封笔

安全面:相对 head 零回归。 13 类攻击逐格对照(同形字 True 变体 ×3、分隔符 ×7、混合大小写、合法 JSON 同形字、marker 劈开):head 与 hybrid 每格一致——#1/#2/#3 全拦(narrow colon 判据接住了 head 原由 includes(':') 拦的带引号键畸形对象),#1v/#4 两版同为 LEAK(#1060 既有性质,success 分支,非本 PR 账)。

可用性:严格优于 head。 Python 字典、Go %v、无引号键噪声、含连字符散文——head 全灭、hybrid 全恢复;畸形带引号键噪声两版一致(fail-closed 残留,与 head 持平);未闭合 { 在结果前两版一致(已知残留)。tsc exit 0。

一个数据点修正:「作者 52 项全绿」这句话被 flake 污染——resolves the port from .dashboard-port when the caller does not pass onehead 上也随机失败(我 6 连跑 head 3 失败、hybrid 4 失败,失败率一致)。与 parser patch 无关,但这正说明阻断②(memo TTL+指纹或止 flake)不是可选项:维护者单跑一次套件就可能撞红,且无法归因。

最终最小可合集合不变:① 本 hybrid patch(strip 归一化 + NFKD + narrow colon)替换裸删;② memo 改 TTL+指纹或让该用例止 flake。🟡 导出后刷新不受预算约束另开 issue。未合码,等申晗。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants