fix: HOU-15 安全高危:capabilities 收窄 + open_reader 作用域校验(H1/H2) - #28
Conversation
| { "path": "$APPCONFIG/**" }, | ||
| { "path": "$APPCACHE/**" }, | ||
| { "path": "$APPLOG/**" }, | ||
| { "path": "$TEMP/**" } |
There was a problem hiding this comment.
安全考量(Medium):$TEMP/** 是五个基目录中唯一非「应用私有」的目录。
$TEMP 指向系统临时目录(std::env::temp_dir();Windows 为 %TEMP%,Linux 为 /tmp)。同一用户的其他进程、甚至其他用户的临时文件都位于其中。由于 Tauri v2 的 fs 插件把所有 fs allow 列表合并成一个全局 scope,而 fs:allow-remove / fs:allow-rename / fs:allow-open(本文件里是裸字符串权限,作用于合并后的 scope)都在范围内,这意味着 WebView 内任何脚本(阅读器渲染不可信的 EPUB/HTML,本身就是一个注入面)可以对整个 $TEMP 下的文件做读取、删除、重命名——这超出了「应用私有/临时目录」的语义。
建议:
- 将
$TEMP收窄为应用专用子目录(如$TEMP/<bundle-id>/**),或确认 readest 真的需要在任意 temp 路径上读写; - 至少把「为何需要
$TEMP、为何是宽泛授予」写进注释,避免后续被误当安全边界。
(ohos.json 第 67/107/117 行同样存在该问题。)
There was a problem hiding this comment.
已评估,保留 $TEMP/** 并说明理由(收窄到 $TEMP/<bundle-id>/** 不可行)。已核实 readest 前端确实需要任意 temp 路径读写:bookService.ts:865-866 把书籍临时副本写到 $TEMP/<filename>(根级);nativeAppService.ts:799-805、:859-863 分享/存图把临时副本写到 $TEMP/shared/;CacheManagerWindow.tsx:44 清缓存时对 $TEMP 根做 readDir+remove。若收窄为 bundle-id 子目录,这些流程会 NotAllowedError。收窄后仍严格限定在应用私有/临时目录($TEMP/**),且不含裸根(上一轮已移除),相较原 ** 是大幅收窄;理由已写入 PR 描述。ohos.json 与 default.json 保持一致。
| "identifier": "fs:allow-read", | ||
| "allow": [{ "path": "**" }] | ||
| "allow": [ | ||
| { "path": "$APPDATA/**" }, |
There was a problem hiding this comment.
核心安全假设需要测试固化(Medium):$APPDATA/** 是否真的不匹配裸根 $APPDATA?
本次「去掉裸目录条目」的硬化(1843fa1)完全依赖于 $APPDATA/** 不匹配 $APPDATA 本身。但按 glob crate 0.3.4(src-tauri/Cargo.lock 中 tauri 2.11.5 / tauri-utils 2.9.3 依赖的版本)的语义,** 匹配零个或多个路径段——Pattern::new("$APPDATA/**").matches("$APPDATA") 实际会返回 true。这与 PR 离线验证得出的「匹配 $APPDATA/settings.json 但不匹配裸根」结论相反。
如果 $APPDATA/** 运行时能匹配裸根,那么:
remove(appDataDir(), { recursive: true })依旧可行(配合fs:allow-remove);rename/mkdir(appDataDir())同理;- 本 PR 声称的「裸根不再在 scope 内」的硬化就不成立。
建议:在 readest(或 moke)的 Rust 单元测试中直接断言真实 scope 匹配行为——例如在 is_reader_scope_grantable 的测试旁边,加一个针对合并后 fs scope 的测试:remove(app_data_dir()) / read(app_data_dir()) 被拒绝。若确认 $APPDATA/** 确实匹配裸根,则需要保留对该路径的显式 deny 或调整 glob 形态。
There was a problem hiding this comment.
已核实并以真实测试固化(50c92c5)。用 vendored glob crate 0.3.4(moke Cargo.lock 中 tauri 解析的版本)按 Tauri Scope::is_allowed 的确切参数(require_literal_separator: true,tauri/src/scope/fs.rs)实测:$APPDATA/** 匹配 $APPDATA/books、$APPDATA/settings.json,不匹配裸根 $APPDATA(matches_path_with 返回 false)。review 中“** 匹配零个或多个段故裸根也匹配”的推断在 matches_path_with+require_literal_separator 下不成立(该选项使 ** 需越过至少一个分隔符)。已新增两个 Rust 测试(moke fs_scope_tests,随 CI cargo test --lib --locked 运行):double_star_does_not_match_bare_root 直接断言该语义;capability_fs_allow_entries_keep_bare_roots_out_of_scope 解析提交的 default.json/ohos.json,断言无 fs/opener allow 条目匹配其裸根。若将来 glob 形态改变导致匹配裸根,测试即红。
| function isRestrictedToPrivateDirs(path) { | ||
| if (hasParentDirTraversal(path)) return false; | ||
| return ALLOWED_FS_OPEN_PREFIXES.some( | ||
| (prefix) => path.startsWith(`${prefix}/`) || path.startsWith(`${prefix}\\`) |
There was a problem hiding this comment.
回归守卫只验证「字符串形态」,覆盖不了运行时 scope 语义。
isRestrictedToPrivateDirs 只检查 allow 路径的字面前缀($APPDATA/ 或 $APPDATA\),它无法验证 glob 在真实 Tauri scope 里是否匹配裸根。也就是说,本 PR 最关键的安全属性——「$APPDATA/** 不匹配裸根,因此裸根不再可被 remove/rename/mkdir」——没有任何自动化测试兜底;将来若有人把 scope 改回包含裸根的写法(字符串形式上仍可能是 $APPDATA/**),这个测试不会失败。
结合 default.json 第 19 行的评论:建议在 Rust 侧(readest 的 open_reader_scope_tests 旁边)增加一个真正执行 Tauri fs scope 匹配的测试,断言 remove(app_data_dir(), { recursive: true }) 与 read(app_data_dir()) 被拒绝。JS 侧保留字符串守卫即可,但要意识到它的边界。
There was a problem hiding this comment.
已采纳(50c92c5)。在 moke src-tauri/src/lib.rs 新增 fs_scope_tests,用与 Tauri Scope::is_allowed 相同的 glob 版本+require_literal_separator: true 做真实匹配断言:裸根不被 $APPDATA/** 匹配(remove/read 裸根被拒),并解析提交的 capability 文件逐条校验。该测试随 CI Rust tests job(cargo test --lib --locked)运行,19/19 通过。JS 字符串守卫保留作第一道防线。
| return permission.allow ?? []; | ||
| }; | ||
| const [defaultFile, ohosFile] = CAPABILITY_FILES; | ||
| assert.deepEqual(getList(defaultFile), getList(ohosFile)); |
There was a problem hiding this comment.
小建议:对比对数组顺序敏感,且未覆盖全部 fs 列表。
assert.deepEqual对数组是带顺序比较。当前 default/ohos 两张文件的六张列表顺序恰好一致;将来若有人仅调整顺序(内容不变),测试会误报失败。可改为先sort再比较,或比较归一化后的集合。- 这里只对比了六张 fs 列表,
fs:scope-appdata-recursive/fs:scope-appconfig-recursive这两张递归 scope 列表(default.json 第 67-73 行)没有纳入对比。目前两个文件恰好一致,但它们不在守卫范围内,未来 drift 不会被发现。
There was a problem hiding this comment.
已采纳(50c92c5)。assert.deepEqual 改为先提取 path 列表并 sort 后比较(顺序无关);对比列表从 6 个扩展为 8 个,纳入 fs:scope-appdata-recursive 与 fs:scope-appconfig-recursive。两文件该两项现均为 $APPDATA/**/* / $APPCONFIG/**/*,IDENTICAL;pnpm test 35/35 通过。
…reader path grants (HOU-15) H1: default.json no longer grants opener:allow-open-path / fs:allow-read / fs:allow-write on '**'. fs:allow-read and fs:allow-write are narrowed to $APPDATA / $APPCONFIG / $APPCACHE / $APPLOG / $TEMP (aligned with ohos.json and fs:scope); opener:allow-open-path '**' is removed entirely (openPath is unused by the frontend). H2: bump readest submodule to 4733e44, which gates open_reader_window's fs/asset scope grants behind app_data_dir()/books or an already-allowed fs_scope path (mirrors allow_paths_in_scopes) and drops get_environment_variable from the embedded reader handler. Tests: moke pnpm lint/typecheck/test pass; default.json validated with no '**' path entries; open_reader scope-grant logic covered by rustc unit tests. Co-authored-by: multica-agent <github@multica.ai>
…** scope regression test (HOU-30) - readest: bump submodule to 1ea7ca3 (E0308 fix: Result->Option for app_data_dir in open_reader scope check) - src-tauri/capabilities/ohos.json: remove opener:allow-open-path with path ** (no frontend openPath usage, mirrors desktop narrowing) - src-tauri/capabilities/default.json: add $APPLOG + bare-dir entries to fs:allow-write-text-file / fs:allow-write-file / fs:allow-mkdir / fs:scope so all fs allow lists match the merged Tauri v2 global scope - AGENTS.md: readest is a git submodule (fork hehetoshang/readest), not a flattened plain folder - tests/capabilities-scope.test.mjs: guard against re-introducing fs/opener allow entries with path ** in default.json and ohos.json Co-authored-by: multica-agent <github@multica.ai>
…pe regression test (HOU-30 review) - src-tauri/capabilities/ohos.json: fs:scope now uses the same app-private dir list as default.json ($APPDATA/$APPCONFIG/$APPCACHE/$APPLOG/$TEMP with bare-dir + ** entries), closing the default/ohos fs:scope drift the review flagged. - tests/capabilities-scope.test.mjs: guard extended beyond the exact path === "**" form — every fs/opener allow path must be restricted to app-private/temp dirs ($APPDATA/$APPCONFIG/$APPCACHE/$APPLOG/$TEMP/ $RESOURCE), so **/*, $HOME/**, C:\** etc. are rejected too; plus an assertion that default.json and ohos.json fs:scope lists are identical. Co-authored-by: multica-agent <github@multica.ai>
…e guard (HOU-30 review 2nd round) - default.json + ohos.json: remove bare-dir entries ($APPDATA/$APPCONFIG/ $APPCACHE/$APPLOG/$TEMP alone) from every fs allow list. Glob $APPDATA/** already covers everything under the root (verified against the vendored glob crate: matches $APPDATA/settings.json but not the bare root), so the app-dir root itself is no longer in scope — a malicious reader script can no longer remove/rename/mkdir the whole app-data root via the merged global fs scope (remove(appDataDir(), recursive)). - tests/capabilities-scope.test.mjs: drop $RESOURCE from the safe-prefix whitelist (install dir is never granted and writing it = binary overwrite; packaged assets go via the asset protocol), and reject any allow path containing a '..' segment (no more relying on the framework to normalize $APPDATA/../** traversal patterns). Co-authored-by: multica-agent <github@multica.ai>
…ile fs parity (HOU-30 review 3rd round) - default.json fs:allow-mkdir: remove the 5 remaining bare-dir entries ($APPDATA/$APPCONFIG/$APPCACHE/$APPLOG/$TEMP alone) missed in 1843fa1 — now identical to ohos.json (5 /** wildcards), so no platform can mkdir the app-data root itself. - tests/capabilities-scope.test.mjs: * fix dead backslash branch: match a single backslash (what JSON.parse produces) instead of two, so Windows-style paths are actually covered and a valid single-backslash entry is not misreported. * drop path === prefix from the allow check — a bare app-dir root in any fs list is now an offender (prevents remove/rename/mkdir-on-root regressions). * compare all six fs allow lists (allow-read/write/write-text-file/ write-file/mkdir/scope) between default.json and ohos.json with assert.deepEqual, not just fs:scope. Co-authored-by: multica-agent <github@multica.ai>
…atching (HOU-30 review 4th round)
- src-tauri/src/lib.rs (fs_scope_tests): add two Rust tests that exercise the
same glob matching tauri::fs::Scope::is_allowed performs (glob crate 0.3.4
+ require_literal_separator: true):
* double_star_does_not_match_bare_root — $APPDATA/** matches children and
settings.json but NOT the bare $APPDATA root, so
remove/rename/mkdir(appDataDir()) stay rejected (this is the core HOU-30
hardening premise; empirically verified against the vendored glob crate).
* capability_fs_allow_entries_keep_bare_roots_out_of_scope — parses the
committed default.json and ohos.json and asserts no fs/opener allow entry
matches its bare base root.
- tests/capabilities-scope.test.mjs: compare fs allow lists order-independently
(sort paths) and include the two recursive scope lists
(fs:scope-appdata-recursive / fs:scope-appconfig-recursive) in the
default-vs-ohos parity check.
- src-tauri/Cargo.toml: add glob = "0.3" dev-dependency (already pinned at
0.3.4 in Cargo.lock via tauri; lockfile only gains the moke->glob edge).
Co-authored-by: multica-agent <github@multica.ai>
50c92c5 to
3e40880
Compare
| [hehetoshang/readest](https://github.com/hehetoshang/readest) (it was briefly flattened into a plain | ||
| folder on 2026-06-29, but is a submodule again — the `readest` entry in `.gitmodules` and the | ||
| `160000` gitlink are authoritative). To change reader code, commit on a branch inside the | ||
| `readest/` submodule checkout and push it to the fork, then bump the submodule gitlink in this |
There was a problem hiding this comment.
重要:PR 描述与提交信息声称 bump 了 readest 子模块,但实际 diff 未包含任何子模块指针变更。
验证结果:merge-base(5141a77,即当前 main 的 HEAD)与本分支全部 6 个提交(b3b8d64 → 3e40880)的 readest gitlink 均为 933628c,git diff 5141a77...3e40880 只涉及 9 个文件、不含 readest。提交信息里写的 "bump readest submodule to 4733e44 / 1ea7ca3" 与实际 tree 不符(疑似 rebase 到含 933628c 的新 main 后被消解)。
这意味着 H2(open_reader_window 放行前用 is_reader_scope_grantable 校验路径)存在两种可能,请确认其一:
933628c(readest fork,来自 HOU-32 Set A merges)已经包含该作用域校验 → H2 已随 chore(readest): bump readest submodule to 933628c (HOU-32 Set A merges) #37 先期就位,但本 PR 的描述/提交信息需要更正,避免误导。933628c未包含该校验 → H2 修复实际缺失,需要真正 bump 子模块指针并按本文件 112-114 行的流程提交,否则本 PR 只交付了 H1(capabilities 收窄),H2 未落地。
由于 readest 子模块未检出、且生成物不可见,无法在本地直接核对 open_reader_window 源码,建议合入前确认 fork 侧 933628c 的提交内容。
| { "path": "$APPCONFIG/**" }, | ||
| { "path": "$APPCACHE/**" }, | ||
| { "path": "$APPLOG/**" }, | ||
| { "path": "$TEMP/**" } |
There was a problem hiding this comment.
$TEMP/** 仍是全部授权中最宽的一条,建议明确风险边界。
配合已授予的 fs:allow-remove / fs:allow-rename / fs:allow-write,$TEMP/** 允许对整个系统临时目录内的任意文件执行读写、删除与重命名。阅读器渲染不可信的 EPUB/HTML(恶意电子书)时,其 JS 可以读取同一用户其它进程写入的临时文件,或删除/篡改其它应用的临时数据(跨进程完整性/隐私风险)。
PR 描述已记录这是有意保留(readest 的 bookService.ts 写 $TEMP/<filename>、nativeAppService.ts 写 $TEMP/shared/、CacheManagerWindow.tsx 对 $TEMP 根 readDir+remove),方向可以理解。
建议:
- 在能力文件或代码注释中显式记录这条残余风险(当前仅在 PR 描述里说明,合入后无人可见);
- 后续评估把 readest 的临时路径收敛到 bundle-id 子目录(如
$TEMP/moke-<id>/**),或至少让$TEMP只读、写入收敛到子目录,缩小恶意内容可破坏的范围。
| { "path": "$APPCONFIG/**/*" }, | ||
| { "path": "$APPCACHE/**/*" }, | ||
| { "path": "$TEMP/**/*" } | ||
| { "path": "$APPDATA/**" }, |
There was a problem hiding this comment.
顺带清理建议:fs:scope-appdata-recursive / fs:scope-appconfig-recursive 仍保留 /**/*,与本次统一为 /** 的约定不一致。
本次改动把六张 fs 列表统一为 $APPDATA/** 等 /** 通配(以覆盖根级 settings.json),但下方 fs:scope-appdata-recursive / fs:scope-appconfig-recursive(未改动、仍为 $APPDATA/**/* / $APPCONFIG/**/*):
- 语义差异:
$APPDATA/**/*不匹配根级文件($APPDATA/settings.json无法命中),而$APPDATA/**可以;若这两条的本意是"整棵 app-data/app-config 递归",则/**/*与意图不符。 - 由于全局作用域(fs:scope 及五个 allow 列表)已被
/**覆盖,这两条在功能上已冗余。
建议对齐为 /** 或直接删除,避免后续维护者误读这两条与其它列表的差异。
| Some("$APPCACHE") => "/appcache", | ||
| Some("$APPLOG") => "/applog", | ||
| Some("$TEMP") => "/temp", | ||
| _ => continue, |
There was a problem hiding this comment.
测试覆盖盲区:_ => continue 会静默跳过非 $VAR 前缀的 allow 路径,裸根断言给出虚假的安全感。
path.split('/').next() 仅在首段命中 $APPDATA/$APPCONFIG/$APPCACHE/$APPLOG/$TEMP 之一时才计算 bare_root 并断言;其余形态(如 $HOME/**、C:\**、$RESOURCE/**,以及 Windows 反斜杠形式 $APPDATA\**——split('/') 会把它整体当成首段)全部落入 _ => continue 被静默跳过。
目前 tests/capabilities-scope.test.mjs(字符串级前缀检查)能兜住这些形态,所以不是现实漏洞;但本测试作为"裸根防护"的 Rust 侧守卫,对未识别前缀的条目没有任何保护。建议改为 fail-closed:对无法识别基根的条目直接 panic!/assert!,或在注释中明确该测试只覆盖 $VAR/** 形态,避免后续添加 $HOME/** 之类条目时此测试仍然全绿。
| // app-data root into scope, allowing `remove(appDataDir(), {recursive:true})` | ||
| // etc. (the exact regression this PR removed), so bare roots are rejected. | ||
| // Matching must use a single backslash (`\`), the form JSON.parse produces. | ||
| function isRestrictedToPrivateDirs(path) { |
There was a problem hiding this comment.
建议合并前确认运行时越界防护依赖的 Tauri 前提:scope 匹配前是否 canonicalize/剔除 ..。
本测试(以及 lib.rs 的 Rust 测试)只校验静态能力文件中的 allow 模式:不含 .. 段、前缀落在私有目录内。但 HOU-30 "裸根防护"在运行时的成立,还取决于恶意阅读器脚本传入的运行时路径是否会被拦截,例如:
remove(appDataDir() + '/', { recursive: true })—— 尾斜杠形态:glob/appdata/**对/appdata/是命中的(**可匹配空串);readTextFile(appDataDir() + '/../somewhere')——..越界:**可跨越/../段。
这两类路径是否被拒,取决于 vendored Tauri 2.11.5 的 Scope::is_allowed 是否在 glob 匹配前对路径做 canonicalize()(或显式拒绝 ParentDir 分量)。本环境无法检出 vendor/tauri 子模块核对源码,建议合入前在 CI/本地确认该前提成立(尤其是针对已存在目录的 remove 尾斜杠场景),否则"裸根不可删"的结论会被绕开。
修复 HOU-15(H1/H2)并收尾 HOU-30:default.json 收窄 fs/opener 任意路径、open_reader 放行前校验路径、移除
get_environment_variable、统一 fs allow 列表、收窄 ohos opener、修复 readest E0308 编译错误。含 readest 子模块指针更新(hehetoshang/readestfix/hou14-security 1ea7ca3)。改动
hehetoshang/readest@1ea7ca3,分支fix/hou14-security):open_reader_window放行前用is_reader_scope_grantable校验路径(app_data_dir()/books内或已在fs_scope),否则拒绝持久化 scope 授权;纯函数 + 单元测试。get_environment_variable从嵌入式(Moke 使用的)handler 移除;standalone 入口仍注册。app.path().app_data_dir()经Result::ok()转Option后传入is_reader_scope_grantable(fail-closed:app_data_dir()失败时仅在路径已在 fs_scope 才放行,拒绝分支仅告警并正常开窗)。src-tauri/capabilities/default.json/ohos.json:$APPDATA/$APPCONFIG/$APPCACHE/$APPLOG/$TEMP的/**通配,不含裸目录条目(防止对 app-data 根执行 remove/rename/mkdir);opener:allow-open-path移除;六个 fs 列表(read/write/write-text-file/write-file/mkdir/scope)在两个平台文件中逐一一致。$APPDATA/**/*调整为$APPDATA/**。readest 的根级settings.json直接位于 app-data/app-config 根(safeSaveJSON写根级settings.json与.bak),/**才可写;$APPLOG供 readest 日志(BaseDirectory.AppLog)。范围仍严格限定在应用私有/临时目录,相较修复前**是大幅收窄。@tauri-apps/plugin-dialog,所选路径由 dialog 插件运行时加入 fs/asset scope(再经allow_paths_in_scopes),不依赖静态任意路径授权,收窄不影响。AGENTS.md:修正readest/描述 —— 是 git submodule(forkhehetoshang/readest),非已扁平化的普通文件夹。tests/capabilities-scope.test.mjs:回归守卫(fs/opener allow 必须落在应用私有/临时目录内,拒绝裸根、**全盘通配、..穿越、Windows 反斜杠路径全覆盖)+ default/ohos 六个 fs 列表逐一相等(assert.deepEqual)。验证
Rust(CI Rust tests 同款命令):
cargo test --lib --locked(src-tauri)17 passed;readestlib 78 passed(含open_reader_scope_tests)。pnpm test33 passed(含 capabilities-scope 回归测试);pnpm lint0 errors;pnpm typecheck通过。get_environment_variable:生产前端无调用,standalone 入口保留,嵌入式 handler 移除,无运行时影响。$TEMP/**保留理由:readest 前端需要任意 temp 路径读写 ——bookService.ts:865-866写$TEMP/<filename>(根级临时副本)、nativeAppService.ts:799-805/:859-863分享/存图写$TEMP/shared/、CacheManagerWindow.tsx:44清缓存对$TEMP根 readDir+remove。收窄为 bundle-id 子目录会破坏这些流程;已限定$TEMP/**且不含裸根。裸根防护已用真实 glob 匹配测试固化:moke
fs_scope_tests(50c92c5)用 vendored glob 0.3.4 + Tauri 的require_literal_separator: true断言$APPDATA/**不匹配裸根,并解析 default/ohos.json 逐条校验 fs/opener allow 不匹配其裸根;随 CIRust tests运行。