问题与复现
packages/shared/src/thesis/repository.ts 的两个仓库把 id 直接拼成文件名交给 JsonFileStore,而 JsonFileStore 只做 join(rootDir, file),不校验名字(packages/shared/src/storage/json-file-store.ts:43 / :48 / :66 / :102):
ThesisRepository.save / get / delete:`${id}.json`(repository.ts:43 / :51 / :73)
ThesisImpactRepository.path:`impacts/${thesisId}.json`(repository.ts:109-111)
同一个文件族里的 research 仓库已经有等价护栏,并且把契约写进了注释:
// packages/shared/src/research/repository.ts:21-28
// Report IDs are opaque names, not relative paths. Keep the file-name
// contract aligned with the generated research/run IDs at this boundary.
const reportFile = (id: unknown): string => {
if (typeof id !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(id)) {
throw createCodeError('INVALID_ARGUMENT', 'Invalid research report id.');
}
return `research/reports/${id}.json`;
};
而已有回归用例把该契约固定下来(packages/shared/src/research/repository.report-id.test.ts,含 “fails closed when an index contains a path-like report id”)。同一契约在 thesis 侧完全缺失。以 storageDir = <base>/thesis、id = ../escaped 实测:
save(id="../escaped"): NO_THROW
-> 文件写在 <base>/escaped.json(storageDir 之外): true
-> index.json 键: ["../escaped"]
-> list() length=1 ids=["../escaped"] # 正常读接口返回了目录外的文件
-> getBySymbol('NVDA.US') length=1
delete(id="../vanish"): NO_THROW
-> <base>/vanish.json 在 delete() 后仍存在: false # 目录外文件被删除
impacts.append(thesisId="../escaped"): NO_THROW
impacts.list("../escaped"): NO_THROW value=[{...}]
其中 impacts/../escaped.json 归一化后是 <storageDir>/escaped.json —— 与 ThesisRepository 为 id escaped 使用的同一个文件,也就是说影响历史可以写到论文正文文件上。
越界一旦发生就不只是单次调用:save 会把 ../escaped 写进 index.json,此后 list() / getBySymbol() 都会顺着这个键去读 <storageDir> 之外的文件。
可达路径
ThesisRepository.save 是唯一由 IPC 直接喂入调用方自带 id 的入口:
preload apps/electron/src/preload/index.ts:278 update: (thesis) => invoke('thesis:update', thesis)
-> apps/electron/src/main/kernelHost.ts:771-773 thesisUpdate(input: unknown) # 只做 requireObject
-> packages/shared/src/thesis/service.ts:122-124 updateThesis() # 原样落盘
-> packages/shared/src/thesis/repository.ts:43 save() # 拼 `${id}.json`
即 <userData>/thesis 的写入边界完全取决于 renderer 传来的 id 字段。另外 thesisSchema(packages/shared/src/thesis/converter.ts:15-29)把 id 只声明为 z.string(),validateThesis 也不会拦下路径型 id。
预期
id 是不透明名字、不是相对路径:字符集不满足 /^[a-zA-Z0-9_-]+$/ 的 id 应当在触碰文件系统之前抛 INVALID_ARGUMENT,且 index.json 不被改动;索引里已存在的路径型 id 应让读接口 fail closed,而不是照着它读到目录之外。
复现证据
- OS:Windows 11;Bun 1.4.2;上游 main
3a17eca6
- 新增回归用例
packages/shared/src/thesis/repository.test.ts(7 条):修复前 1 pass / 6 fail,修复后 7 pass / 0 fail
bun test packages/shared/src/thesis --isolate:修复后 25 pass / 0 fail
bun run typecheck:core / i18n / shared / ui / electron 五工作区 exit 0
bun test packages/shared --isolate(全量):1127 pass / 2 fail,两条失败均可在 main 原始字节下复现,与本 issue 无关(见 PR 说明)
范围
只给 thesis / impact 两个仓库补上 research 侧已有的 id 护栏。不改 JsonFileStore 本身(它是有意保持极简的存储底座),不改 id 的生成方式(randomUUID 与现有全部 id 都满足该字符集),不改 thesisSchema,不改任何 IPC 契约。
问题与复现
packages/shared/src/thesis/repository.ts的两个仓库把 id 直接拼成文件名交给JsonFileStore,而JsonFileStore只做join(rootDir, file),不校验名字(packages/shared/src/storage/json-file-store.ts:43/:48/:66/:102):ThesisRepository.save/get/delete:`${id}.json`(repository.ts:43/:51/:73)ThesisImpactRepository.path:`impacts/${thesisId}.json`(repository.ts:109-111)同一个文件族里的 research 仓库已经有等价护栏,并且把契约写进了注释:
而已有回归用例把该契约固定下来(
packages/shared/src/research/repository.report-id.test.ts,含 “fails closed when an index contains a path-like report id”)。同一契约在 thesis 侧完全缺失。以storageDir = <base>/thesis、id =../escaped实测:其中
impacts/../escaped.json归一化后是<storageDir>/escaped.json—— 与ThesisRepository为 idescaped使用的同一个文件,也就是说影响历史可以写到论文正文文件上。越界一旦发生就不只是单次调用:
save会把../escaped写进index.json,此后list()/getBySymbol()都会顺着这个键去读<storageDir>之外的文件。可达路径
ThesisRepository.save是唯一由 IPC 直接喂入调用方自带 id 的入口:即
<userData>/thesis的写入边界完全取决于 renderer 传来的id字段。另外thesisSchema(packages/shared/src/thesis/converter.ts:15-29)把id只声明为z.string(),validateThesis也不会拦下路径型 id。预期
id 是不透明名字、不是相对路径:字符集不满足
/^[a-zA-Z0-9_-]+$/的 id 应当在触碰文件系统之前抛INVALID_ARGUMENT,且index.json不被改动;索引里已存在的路径型 id 应让读接口 fail closed,而不是照着它读到目录之外。复现证据
3a17eca6packages/shared/src/thesis/repository.test.ts(7 条):修复前 1 pass / 6 fail,修复后 7 pass / 0 failbun test packages/shared/src/thesis --isolate:修复后 25 pass / 0 failbun run typecheck:core / i18n / shared / ui / electron 五工作区 exit 0bun test packages/shared --isolate(全量):1127 pass / 2 fail,两条失败均可在 main 原始字节下复现,与本 issue 无关(见 PR 说明)范围
只给 thesis / impact 两个仓库补上 research 侧已有的 id 护栏。不改
JsonFileStore本身(它是有意保持极简的存储底座),不改 id 的生成方式(randomUUID与现有全部 id 都满足该字符集),不改thesisSchema,不改任何 IPC 契约。