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
+17 -16
View File
@@ -369,9 +369,10 @@ async function validateInputPath(
label: string,
): Promise<string> {
const abs = resolve(inputPath)
let stat
// 存在性探测(lstat 本身不跟链接——断链末段在此即 ENOENT);结果只用于"存在与否",
// 类型与白名单判定统一交给下方 realpath 后的物理路径
try {
stat = await lstat(abs)
await lstat(abs)
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === 'ENOENT' || code === 'ENOTDIR') {
@@ -382,17 +383,15 @@ async function validateInputPath(
`${label} 输入无法访问(${code ?? 'unknown'}):${abs}`,
)
}
// 输入本身是符号链接:以 realpath 后的目标做白名单与类型判定(防白名单内链接指向外界)
// 无条件 realpath:只 realpath 末段会漏掉**中间目录段**的符号链接/junction——链接本身
// 位于白名单内、目标在外时,词法前缀判定被绕过(Windows junction 免特权即可构造)。
// realpath 把整条路径解析到物理位置后再做白名单与类型判定;lstat 已确认输入存在,
// realpath 仅在路径上出现断链时抛 ENOENT(归入"输入不存在")。
let real: string
if (stat.isSymbolicLink()) {
try {
real = await realpath(abs)
} catch {
// 断链(目标不存在):realpath 抛 ENOENT——归入清晰的"输入不存在",而非裸系统错误
throw new SourceRegisterError('INPUT_NOT_FOUND', `${label} 输入不存在(符号链接目标缺失):${abs}`)
}
} else {
real = abs
try {
real = await realpath(abs)
} catch {
throw new SourceRegisterError('INPUT_NOT_FOUND', `${label} 输入不存在(或链接目标缺失):${abs}`)
}
let allowed = false
for (const root of allowedRoots) {
@@ -558,9 +557,11 @@ function makeRunId(): string {
// ---- 主入口 ----
/**
* 登记来源输入:校验(全部输入先过白名单/存在性,任何失败零落库零复制)→
* 逐输入摄取(copy+hash)→ site/connection/snapshot 落库 → 报告写盘。
* 返回 RegisteredSnapshot[] 供 CLI 摘要与调用方断言。
* 登记来源输入:校验(全部输入先过白名单/存在性/类型)→ 逐输入摄取(copy+hash)→
* site/connection/snapshot 落库 → 报告写盘。
* 失败口径:校验阶段失败零落库零复制;摄取阶段中途失败**不回滚已落位的副本**
* (content-addressed、重跑幂等无害),但 DB 写入在全部摄取完成后才开始——
* 故任何失败都零落库。返回 RegisteredSnapshot[] 供 CLI 摘要与调用方断言。
*/
export async function registerSource(db: SourceDb, input: RegisterInput): Promise<RegisterResult> {
const startedAt = performance.now()
@@ -568,7 +569,7 @@ export async function registerSource(db: SourceDb, input: RegisterInput): Promis
const storageRoot = input.storageRoot ?? join(REPO_ROOT, 'storage')
const reportRoot = input.reportRoot ?? join(REPO_ROOT, 'artifacts', 'register')
// 阶段 0:全部输入先校验(fail fast,不留下半程副本)
// 阶段 0:全部输入先校验(fail fast——校验失败零落库零复制;摄取中途失败口径见函数 JSDoc)
let wxrReal: string | undefined
let sitemapReal: string | undefined
let mediaReal: string | undefined