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:
@@ -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
|
||||
|
||||
@@ -127,7 +127,7 @@ cms-one/
|
||||
- 副本落 `storage/snapshots/<kind>/<sha256前2位>/<sha256>`;原输入不再被后续任务读取
|
||||
- 路径安全:register 的输入路径必须在允许目录(storage/incoming 或显式绝对路径白名单校验——拒绝越界读取);**wxr:probe 例外:只读诊断命令,接受任意可读路径(Task 8 直读 cms 即用此通道),不写任何东西**
|
||||
- [x] Step 2 实现服务(copy 流式+sha256 双算、媒体清单哈希、DB 写入、报告输出 **artifacts/register/<runId>/reports/**——阶段 2 无 import_jobs,runId 为本次 register 运行生成)+ CLI 接线
|
||||
- [ ] **CLI 执行机制(钉死)**:根 devDependency 加 `tsx`;根 package.json scripts 加 `"sf": "tsx scripts/sf.ts"`;`commander` **定为根依赖**(CLI 在根,adapter-wp 纯函数包不消费);CLI 经 tsx 直吃 TS 源码(**无需预构建 dist**;apps/api 的 Nest 装饰器源码同样由 tsx 运行)。Task 8 命令相应为 `pnpm sf wxr:probe --wxr ...`
|
||||
- [x] **CLI 执行机制(钉死)**:根 devDependency 加 `tsx`;根 package.json scripts 加 `"sf": "tsx scripts/sf.ts"`;`commander` **定为根依赖**(CLI 在根,adapter-wp 纯函数包不消费);CLI 经 tsx 直吃 TS 源码(**无需预构建 dist**;apps/api 的 Nest 装饰器源码同样由 tsx 运行)。Task 8 命令相应为 `pnpm sf wxr:probe --wxr ...`
|
||||
- [x] Step 3 绿(75+新增)→ commit `feat(source): 输入登记服务与 sf CLI(content-addressed 快照)`
|
||||
|
||||
### Task 8: 真实 WXR 冒烟(阶段 2 验收)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user