fix(source): 中间段符号链接逃逸堵截(无条件 realpath);注释口径修正

- validateInputPath 原先只在末段是符号链接时 realpath——中间目录段含 junction
  (链接在白名单内、目标在外,Windows 免特权可构造)时词法前缀判定被绕过,
  会读到白名单外内容(评审 PoC 复现)。改为 lstat 存在性探测后无条件
  realpath(abs),整条路径解析到物理位置再做白名单与类型判定;断链场景
  realpath 抛 ENOENT 仍归入 INPUT_NOT_FOUND(语义不变)
- 回归测试:中间段 junction 逃逸用例(incoming/junc → pkg-outside/escape.xml,
  断言 PATH_NOT_ALLOWED + 零落库);已对修复前代码验证为红(逃逸被接受)、
  修复后绿
- 注释口径修正:registerSource 的"任何失败零落库零复制"只在校验阶段成立——
  摄取中途失败不回滚已落位副本(content-addressed、幂等无害),但 DB 写入
  在全部摄取完成后才开始,任何失败零落库不变
- 计划勾选:Task 7 "CLI 执行机制(钉死)"(已实现并双命令冒烟验证)

全套 261 测试绿(+1);typecheck / lint 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 12:47:07 +08:00
parent 46b823e667
commit 9732e1b684
3 changed files with 58 additions and 18 deletions
+40 -1
View File
@@ -8,7 +8,8 @@
// 媒体目录重跑哈希一致(Windows 分隔符归一化 + mtime 不参与);
// 4. 副本布局:storage/snapshots/<kind>/<sha256前2>/<sha256>(文件)或 <sha256>/(整树),
// 副本内容与原输入一致(后续任务只读副本);
// 5. 路径安全:白名单外 / 越界遍历 / ENOENT / 类型不符均清晰报错且零落库;
// 5. 路径安全:白名单外 / 越界遍历 / **中间段链接逃逸(整路径 realpath 后判定)** /
// ENOENT / 类型不符均清晰报错且零落库;
// 6. 报告落 artifacts/register/<runId>/reports/register.json(runId=时间戳+随机)。
// 运行于 globalSetup 临时库(ADR-015);storage/报告根用 os.tmpdir 临时目录,finally 清理。
// 注:符号链接跳过用目录 junction 验证(Windows 免特权;非 Windows 退化为普通符号链接);
@@ -324,6 +325,44 @@ describe('source:register 服务', () => {
})
})
it('中间段 junction 逃逸被拒:目录段含指向白名单外的链接,整路径按 realpath 判定', async () => {
await withTestDb(async (db) => {
await withCtx(async (ctx) => {
// 布局(复现评审 PoC 形态):白名单根 incoming/ 内放 junction,目标 pkg/ 在白名单外;
// 文件物理落在 pkg/escape.xml——词法前缀判定会把 incoming/junc/escape.xml 误判为界内
const pkg = join(ctx.work, 'pkg-outside')
const incoming = join(ctx.work, 'incoming')
await mkdir(pkg, { recursive: true })
await mkdir(incoming, { recursive: true })
await writeFile(join(pkg, 'escape.xml'), 'escaped')
try {
await symlink(pkg, join(incoming, 'junc'), 'junction')
} catch {
return // junction 创建失败(极端沙箱):静默放行,创建能力不是被测契约
}
const key = `sfreg-${rand()}`
const err: SourceRegisterError = await registerSource(db, {
siteKey: key,
wxrPath: join(incoming, 'junc', 'escape.xml'),
allowedRoots: [incoming],
storageRoot: ctx.storageRoot,
reportRoot: ctx.reportRoot,
}).then(
() => {
throw new Error('中间段 junction 逃逸不应成功')
},
(e: SourceRegisterError) => e,
)
expect(err).toBeInstanceOf(SourceRegisterError)
expect(err.code).toBe('PATH_NOT_ALLOWED')
expect(err.message).toContain('越界')
// 零落库
const sites = (await db.execute(`select id from sites where key = '${key}'`)) as any
expect(sites).toHaveLength(0)
})
})
})
it('符号链接(junction)在媒体枚举中被跳过并记录,清单不受其内容影响', async () => {
await withTestDb(async (db) => {
await withCtx(async (ctx) => {