dsh-tiddlywiki 0.26.5 → 0.26.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,151 +0,0 @@
1
- # dsh-tiddlywiki 全面代码审查(v0.22.10,2026-09-17)
2
-
3
- 审查对象:`1d67543`(v0.22.10)工作区,覆盖 host 数据写路径、HTTP/路由/安全层、进程/生命周期/存储/seed、以及全部 client 代码。
4
-
5
- **结论**:本地四套校验(`verify:static` / `verify:unit` / `verify:e2e` / `verify:large`)全绿,`typecheck` 通过,`npm run build` 重建 `lib/` 与提交版**逐字节一致**(无脏构建产物)。但审查发现 **3 处 P0/P1 安全问题**——v0.19.3 / v0.20.0 建立的「插件密钥命名空间不可经代理读出」这条防线**实测已被绕过**。
6
-
7
- 标注约定:✅ = 我在本机实测/读码亲自复现;○ = 读码确认(未运行时复现)。
8
-
9
- ---
10
-
11
- ## P0 — 安全
12
-
13
- ### 1. ✅ 代理的「密钥命名空间」封锁可用**百分号编码的单段路径**绕过
14
-
15
- `src/host/routes.ts:565-575`(`isBlockedProxyPath`,用于 `/api` 与 `/tw`)
16
-
17
- 守卫只在路径里含**字面量 `/tiddlers/`** 时才生效。而 TW core 的 `get-tiddler-html.js` 路由是 `path = /^\/([^\/]+)$/`(**单段**,用 `decodeURIComponentSafe` 解码),于是把 `/` 编码成 `%2F` 就能绕开那个字面量匹配。
18
-
19
- 本机实测(只读 GET):
20
-
21
- | 请求 | 结果 |
22
- |---|---|
23
- | `GET /dsh-tiddlywiki/tw/tiddlers/$:/plugins/dsh-tiddlywiki/config` | **403**(守卫生效) |
24
- | `GET /dsh-tiddlywiki/tw/%24%3A%2Fplugins%2Fdsh-tiddlywiki%2Fconfig` | **200**,676 字节插件配置 JSON |
25
- | `GET /dsh-tiddlywiki/api/%24%3A%2F...%2Fconfig` | **200**(同上) |
26
- | `GET /dsh-tiddlywiki/get?title=...` | 403(这条路由没问题) |
27
-
28
- 同一编码手法对 `/api` 同样有效。**这次没有泄密**:本机 `$:/plugins/dsh-tiddlywiki/config` 里没有 `bridge.token` / `auth.password`,`git.remote` 也是不含凭据的纯 https 地址——但 README 恰恰叫用户去设 `bridge.token`,一旦设了,任何**同源**读取方(页面内 XSS、被注入的 TW 内容、其它插件)即可拿到明文。
29
-
30
- **修复**:别再用 `/tiddlers/` 这个标记做判定。应把整条被代理路径解码、归一后,判断解码结果是否落在受保护标题上(覆盖 TW 各条路由形态),或在转发前对每个路径段解码后比对。
31
-
32
- ### 2. ✅ `/tw/` 索引页把整个 store(含插件配置)原样吐出
33
-
34
- `src/host/routes.ts:1474-1553`(`handleTwProxy`)
35
-
36
- `$:/core/save/all`(`templates/save-all.tid`)没有排除插件命名空间,所以 `GET /dsh-tiddlywiki/tw/` 会内嵌每一条 tiddler。本机实测:**33,643,875 字节**,且正文中确实包含 `$:/plugins/dsh-tiddlywiki/config`。
37
-
38
- 这条路径完全绕过了 `/admin/state` 的密钥打码(`admin.ts:385-414`)。
39
-
40
- **修复**:在 store 的 publish 过滤里排除 `$:/plugins/dsh-tiddlywiki/`;更彻底的做法是共享 token 根本不进 wiki tiddler。
41
-
42
- ### 3. ✅ `POST /render` 只挡了 `title` 分支,`text` 分支的**转写**可读出被保护条目
43
-
44
- `src/host/routes.ts:1394-1405`
45
-
46
- v0.20.0 的加固只覆盖 `title`;`text` 分支未校验,而 TW 的 `/render` 会解析 `{{…}}` 转写,`contextTitle` 同样未校验。本机实测:
47
-
48
- ```
49
- POST /render {"text":"{{$:/plugins/dsh-tiddlywiki/config}}","type":"text/vnd.tiddlywiki"}
50
- → 200,2744 字节,净化后的片段里是配置 JSON 的完整内容
51
- ```
52
-
53
- 即:加固过的这条路由,本身仍可把密钥渲染出来。
54
-
55
- **修复**:`text` 分支拒绝含受保护标题的正文,并校验 `contextTitle`;或把转写放进受限上下文。
56
-
57
- ---
58
-
59
- ## P1/P2 — 数据安全与生命周期
60
-
61
- ### 4. ○ `tiddlywiki_delete`:回收站索引的守卫发生在**破坏性步骤之后**
62
-
63
- `src/host/tools.ts:761`(`wiki.delete`)先于 `:768-769`(`readTrashIndex` + 中止判定)。
64
-
65
- 于是「快照 PUT 成功 → 原条目已删除 → 索引读失败」时,工具报的是「本次操作已中止」,但实际上原标题**已经没了**,而回收站副本**没进索引**:`trash list` 看不到、`restore` 找不到、`empty` 清不掉——成为永久孤儿(只能从 git 里捞)。
66
-
67
- 代码注释(`tools.ts:762-767`)与回归测试(`verify-audit-fixes.mjs:335`)都声称「先读索引再删除」,与实际执行顺序相反;这也是测试为何没抓到它的原因。
68
-
69
- **修复**:把 `readTrashIndex` 与该守卫上移到快照 PUT 之前;若保留删除后失败的可能,错误信息里必须带上 `trashTitle` 以便恢复。
70
-
71
- ### 5. ○ `assertNoConflict` 把两个令牌当**或**处理,且 `revision` 不跨重启存活
72
-
73
- `src/host/write-policy.ts:276-279`
74
-
75
- 先判 `modified` 命中即 return;再判 `revision` 命中即 return。TW 的 `revision` 是 `core/modules/wiki.js` 里的内存 hashmap(`changeCount`,**不持久化**,重启后每条回到 1)——我已在 `node_modules/tiddlywiki/core/modules/wiki.js:151-155` 确认。
76
-
77
- 后果:agent 读到 `revision: 1` → 人类在 TW 里改动 → 一次 pull/重启让 revision 复位 → agent 带 `expectedRevision: 1` 的写入被**接受**,人类的改动被静默覆盖。更糟的是**同时传两个令牌并不更安全**:`modified` 不匹配时会落到 `revision` 分支继续写。
78
-
79
- **修复**:调用方给了哪个令牌就必须匹配哪个,任一不匹配即拒;并修正三处工具描述(`tools.ts:503`、`719`、`973`)不要暗示 revision 跨重启有效。
80
-
81
- ### 6. ✅ `fs.watch` 没有 `'error'` 监听器,可让整个 dsh web 进程退出
82
-
83
- `src/index.ts:279-294`(`watchWiki`)
84
-
85
- 只挂了 `'change'`。Node 的 EventEmitter 在**没有 `'error'` 监听器**时会把 `error` 事件抛成未捕获异常(本机已用最小复现确认),而宿主没有 `uncaughtException` 处理器。`fs.watch` 的 `error` 是**异步**事件(Linux `ENOSPC` 用尽 inotify watch、Windows `EPERM`),外面那个 try/catch 只能接住同步创建失败。
86
-
87
- **修复**:`watcher.on('error', () => {})`(我们自己的写入仍会 `touch()` 自动提交,退化是安全的)。
88
-
89
- ### 7. ○ `stop()` 不能取消在途的 `start()`,可泄漏孤儿 TW 子进程
90
-
91
- `src/host/wiki.ts:550-587` vs `303`/`307`/`351`
92
-
93
- `startOnce()` 在 `await this.ensureWiki()`(302)与 `await this.findFreePort()`(307)处让出执行权;`stop()` 在此期间把 `this.child` 取走并置空(559-560)。等 `startOnce()` 继续走到 `spawn()`(351)并赋值 `this.child` 时,**已经没人会去杀它**了。
94
-
95
- 可达路径:`scheduleRestart()` 的定时器在调用 `start()` **之前**就清掉了 `this.restartTimer`(517),所以随后的 `stop()` 既没定时器可取消、也看不到子进程;以及 `index.ts:884-888` 那个 3 秒 teardown race 超时。
96
-
97
- **修复**:在 `spawn()` 前立刻复查 `if (this.stopping) return this.status()`(`stopping` 已有,无需新状态)。
98
-
99
- ### 8. ○ 回滚失败会让自动提交**永久停摆**
100
-
101
- `src/host/wiki-switch.ts:86-99` + `src/index.ts:753-754`
102
-
103
- 外层失败路径已经先跑过 `teardownExtras()`(753,会 dispose AutoCommitter 与 fs watcher)再 `applyLocation`。而 `restore()` 里 `startServer()`(90)排在 `setupExtras()`(92)**之前**——若回滚时 `startServer()` 抛错(例如旧 wiki 撞上硬就绪上限),`catch` 直接返回,`setupExtras()` 永不执行,且没有任何其它路径会重新装配。此后所有 wiki 写入都不会再被提交,而旧子进程可能因「迟到就绪」复探仍在服务,`/status` 看起来一切正常。
104
-
105
- **修复**:在 `restore()` 里用 `finally` 重新装配 extras。
106
-
107
- ### 9. ○ `runSwitch` 缺 `disposed` 守卫
108
-
109
- `src/index.ts:736-770`。`disposed` 在作用域内(571 声明)但 `runSwitch` 从不检查:热重载/关闭期间的在途切换会重新挂上 committer+watcher,并可能在 teardown 已 `stop()` 之后 spawn 出新的 TW 子进程。
110
-
111
- ---
112
-
113
- ## P3 — 低危 / 行为瑕疵
114
-
115
- 10. ✅ **`escapePromptBraces` 对连续花括号失效**(`src/host/prompt.ts:142-144`)。`replace(/\{\{/g, …)` 只替换**不重叠**匹配,本机实测:3 个及以上连续 `{` 会残留 `{{`(`{{{name}}}` → `{<ZWSP>{{name}}}`),而 DSH 对未知变量**直接抛错**,会炸掉整个系统提示词装配——正是该函数存在的理由。现有断言(`verify-prompt.mjs:141`)只覆盖最简单的单个 `{{cwd}}`。**修复**:循环替换至无 `{{`,或在任意相邻两括号间插入 ZWSP。
116
- 11. ✅ **`readLimit` 把空参数当 0**(`src/host/routes.ts:315-318`):`?limit=` → `Number('' ?? 15)` = 0 → 夹到 1。本机实测 `/recent?limit=` 返回 `limit=1`,而 `/recent` 是 15。**修复**:空值按缺省处理(`readOptionalLimit` 已是对的)。
117
- 12. ✅ **`git pull` 失败后无条件 `rebase --abort`**(`src/host/git.ts:159-160`):只要 pull 失败就 abort——包括用户在 wiki 仓库里**自己正在做的 rebase**。本机真实仓库复现:`rebase in progress: True` → `pull --rebase` 报 `Pulling is not possible because you have unmerged files` → 插件执行 `rebase --abort` → 用户的 rebase 与冲突解决被清掉(提交仍在 reflog/ORIG_HEAD,可恢复)。**修复**:仅在确有 rebase 进行中且失败原因是冲突时才 abort。
118
- 13. ○ **clip-bridge 的 OPTIONS 预检排在 Host 白名单之前**(`src/host/clip-bridge.ts:688` 早于 `:694`):DNS-rebinding 页面能拿到 204 + `Access-Control-Allow-Origin: *` + `Allow-Private-Network: true`(仅信息泄露:确认回环端口有服务;后续 POST 仍 403)。
119
- 14. ○ **`ensureTiddlerTimestamps` 在只给了 `created` 时把 `modified` 钉死成 `created`**(`src/host/tw-api.ts:189`)。真实受害者就是配置 tiddler:`config.ts:215-221` 每次保存都只带 `created`,于是 `modified` 永远停在首次写入的时刻——与该文件 `198-200` 行自述的意图(「modified tracks edits」)相反。**本机磁盘实测**:`$__plugins_dsh-tiddlywiki_config.json.meta` 里 `created` 与 `modified` **完全相同**(`20260917033457660`)。`routes.ts:447-457` 因为显式传了 `modified` 才躲过。**修复**:缺的那一半用 `now`(仍保留「绝不改写已有值」的保证)。
120
- 15. ○ **`append` 在 `mode=prepend` 时回执里谎报段落**(`src/host/tools.ts:884` vs `:908`,render 在 `:866`):`heading` 只在 append 分支生效,却无条件回显,模型会以为写进了「日志」段。
121
- 16. ○ **`$:/` 系统条目其实会被软删除**(`src/host/tools.ts:743-744`):注释写「system tiddlers are never nested」,但条件只排除了 `TRASH_PREFIX`。`delete('$:/dsh-tiddlywiki/trash-index')` 会把索引本身移入回收站,随后读到 404 → 当成「回收站为空」→ 索引被重写成 1 条,**丢掉全部回收站记录**(正是 v0.19.5 想防的那类损失,只是换了扇门)。
122
- 17. ○ **`/render` 内层 catch 把超限/中断报成 400**(`src/host/routes.ts:1377-1382`):`body too large` 本应 413。
123
- 18. ○ **`put` 能把二进制附件改成损坏的文本**(`src/host/tools.ts:534`):按设计保留基底 `type`,于是对 `type: image/png` 的既有附件写入 markdown,type 仍是 `image/png` → 图裂,且回执里 `typeChanged` 为空(类型确实没变)。
124
-
125
- ---
126
-
127
- ## 已复核为正确的部分(尽力想证伪但没成功)
128
-
129
- - **写策略的字段保留**:`cleanTiddler` 确实摊平了 `get-tiddler.js` 塞进 `fields` 的未知字段;`applyCustomFields` 拒绝 `title/text/tags/created/modified` 而放行 `type`;「覆盖保留 type/tags/自定义字段」的契约成立。
130
- - **v0.22.10 的时间戳语义**:`formatTiddlerDate` ⇄ `parseTiddlerDate` 对 **1970–9999 年间所有可达时刻**往返一致(含 `.000`、`+1ms`、`+999ms`、1969 年末);`stampTiddlerTimes` 始终从基底取 `created`、从 `now` 取 `modified`,不会让 `modified` 早于 `created`;覆盖不会丢原 `created`;`ensureTiddlerTimestamps` 只补缺、绝不改写(回收站快照、恢复、`/edit` 草稿的显式值都存活)。仅当 `new Date()` 落到公元 1000 年前/10000 年后才不往返——生产不可达。
131
- - **回收站快照/恢复的时间戳**:删除时保留原 `created`+`modified`,恢复同理,所以恢复的笔记不会跳到「最近修改」顶部;索引的 404 与读失败区分正确。
132
- - **`batch_put` 有界并发**:结果按下标回填、逐条容错、计数器只在同步段内改、`autoCommit()` 在循环外只调一次。
133
- - **`attach` 覆盖保护**:同名默认拒绝、需 `force`;附件本身与 `noteTitle` 嵌入都走共享写策略。
134
- - **HTTP 层的方法校验**:routes.ts 与 admin.ts 的每一个写 handler 都声明了方法、每一个读 handler 都用 `rejectNonRead`,29 处注册全被 `guardHandler` 包裹;`readBodyStream` 在 `aborted`/`close` 上会 settle,不会挂死请求。
135
- - **`/admin/restart` 即便没走 `beginMutation` 也不可能双重重启**:`WikiServer.start()/restart()` 是单飞的。
136
- - **密钥打码**:`maskConfigSecrets`/`stripMaskedSecrets` 对 `bridge.token`、`auth.password`、`ui.sendToAgent.token`、`git.remote` 的往返正确;本机 `/status` 返回的是不含凭据的 remote。
137
- - **SSRF 守卫**:`isPrivateAddress`/`ipv6ToBytes` 是逐字节且完备的(`::1`、`::ffff:7f00:1`、NAT64、6to4、IPv4-compatible 都判私网),解析不可判时 fail-closed;`resolvePublicTarget` 用自定义 `lookup` **钉住**已校验 IP 防 rebinding,每一跳重定向都重新校验;`hostAllowed` 字母精确;`safeTokenEqual` 两侧哈希。
138
- - **seed 的 ONE-SHOT 语义**:只有 404 才算缺失(`readSeedTiddler`/`presentOf`/`inspectSeedContent`),非 force 绝不覆盖已存在条目;`refreshSeedMarker` 跳过无变化的 PUT。
139
- - **子进程退出处理**:身份检查防止被替换的旧子进程清掉新子进程的状态;`stop()` 会先清「迟到就绪」复探。
140
- - **净化器**:`codePointToChar` 已把数字实体夹在 `0x10FFFF`,`&#1114112;` 不再抛 `RangeError`。
141
- - **client 半部**:我逐文件读了 `tw-frame.ts`、`panel.ts`、`index.ts`、`sidebar-entry.ts`、`theme-sync.ts`、`quick-note-dock.ts`、`session-summary.ts`、`status-cache.ts`、`sync-button.ts`、`editor-popup.ts`、`knowledge-fab.ts`、`tool-views.ts`、`note-widget.ts` 的关键段:定时器/监听/observer/ResizeObserver 在 dispose 路径上都被回收、可见性都在 `await` 之后复查、`iframe.src` 从不作为加载判据(只用 `dataset.loaded`)、`contentDocument` 读取都有守卫、`dangerouslySetInnerHTML` 只吃宿主净化过的 `/render` 输出。**未发现新缺陷。**
142
-
143
- ---
144
-
145
- ## 建议的修复顺序
146
-
147
- 1. **P0 三连**(安全,同一个根因族):代理路径判定重写(#1)+ store 排除插件命名空间(#2)+ `/render` 的 `text`/`contextTitle` 校验(#3)。
148
- 2. **P1/P2 数据安全**:#4(删除前先读索引)、#5(令牌 AND 语义 + revision 不可持久化的措辞)、#6(`fs.watch` error 监听)、#7(`spawn` 前复查 `stopping`)、#8(`restore()` 用 finally 重装 extras)、#9(`runSwitch` 的 disposed 守卫)。
149
- 3. **P3**:#10–#18,其中 #10(提示词转义)、#11(空 limit)、#14(配置 `modified` 钉死)最值得顺手带上。
150
-
151
- 守门建议:#1/#2/#3 各加一条 E2E(用编码路径与转写正文断言 403),#4 加一条「索引读失败时原条目必须仍在」的顺序断言,#5 加一条「modified 不匹配即便 revision 相同也必须拒」的纯函数断言,#6/#7 各加一条源码级或注入式断言,#10 扩展现有 `verify-prompt.mjs` 断言(连续花括号),#11 加一条空 `?limit=` 的路由断言。