Skip to content

fix(thesis): 投资论文仓库未校验 id 的文件名边界,路径型 id 会越出 <userData>/thesis 读写删 #302

Description

@wxrbyte

问题与复现

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 契约。

Activity

  1. wxrbyte commented on Oct 7, 2026

    @wxrbyte
    ContributorAuthor

    /claim

  2. added
    claimedClaimed by a contributor and currently in progress
    on Oct 7, 2026
  3. github-actions commented on Oct 7, 2026

    @github-actions

    @wxrbyte 已认领该 Issue,并已自动设为 Assignee。提交 PR 时请在描述中写 Closes #302。

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

Metadata

Metadata

Assignees

Labels

claimedClaimed by a contributor and currently in progress

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions