fix(测试点): 解压前按条目头拦大小,防 zip bomb

原来 unzipSync 先把整包解进内存,再逐个查大小 —— 检查之前就已经解完了。
实测一个 300KB 的 zip(10 个 30MB 的全零文件)让进程 RSS 涨 455MB,
容器只有 512MB。

- 第一遍 unzipSync 的 filter 只读目录、恒返回 false,按条目头的 originalSize
  累计,单个超 32MB / 总计超 128MB 就抛;第二遍才真解,而且只解 N.in / N.out /
  N.sql(N ≤ 500),压缩包里别的文件一律不解
- originalSize 可以造假,但 fflate 按它分配输出缓冲、不扩容(解多了只截断),
  所以按它限额就是按实际内存限额

实测:同一个 zip bomb 被拦下时 RSS 只涨 1MB;单文件 300MB 的包按单文件上限拦;
正常测试点照常落盘、CRLF 照常归一、带 ../ 的条目名照常被忽略。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 15:14:12 +08:00
1 parent 7a390323fe
commit b105fe3cbd
1 file changed
+40 -5
+40 -5
View File
@@ -77,6 +77,38 @@ function collectSqlScripts(names: Set<string>) {
return scripts
}
/**
* 解压**之前**按条目头拦大小。原来是先 `unzipSync` 整包解进内存、再逐个查大小 ——
* 一个几十 KB 的 zip bomb 在检查之前就能把 512MB 的容器撑爆。
*
* 条目头里的 `originalSize` 是压缩包自己声明的,可以造假,但 fflate 按这个值分配
* 输出缓冲、不会扩容(`inflateSync(..., { out: new u8(su) })`,解多了只会截断),
* 所以按它限额就是按实际内存限额。顺带只解需要的文件名,压缩包里别的东西一律不解。
*/
function wantedEntry(file: { name: string }) {
const match = /^(\d+)\.(in|out|sql)$/.exec(file.name)
return !!match && Number(match[1]) >= 1 && Number(match[1]) <= MAX_CASES
}
function entryFilter() {
let total = 0
return (file: { name: string; originalSize: number }) => {
if (!wantedEntry(file)) return false
if (file.originalSize > MAX_ENTRY_BYTES) {
throw new TestCaseError(
`测试点 ${file.name} 超过 ${MAX_ENTRY_BYTES / 1024 / 1024}MB`,
)
}
total += file.originalSize
if (total > MAX_TOTAL_BYTES) {
throw new TestCaseError(
`测试点总大小超过 ${MAX_TOTAL_BYTES / 1024 / 1024}MB`,
)
}
return true
}
}
export interface ProcessedTestCase {
testCaseId: string
info: TestCaseEntry[]
@@ -88,16 +120,19 @@ export async function processTestCaseZip(
): Promise<ProcessedTestCase> {
let files: Record<string, Uint8Array>
try {
files = unzipSync(archive)
} catch {
// 第一遍只读目录、一个字节都不解(filter 恒返回 false),超限就在这里抛;
// 第二遍才真解。否则 fflate 边过滤边解,超限之前已经解了上百 MB
const check = entryFilter()
unzipSync(archive, { filter: (file) => (check(file), false) })
files = unzipSync(archive, { filter: wantedEntry })
} catch (error) {
if (error instanceof TestCaseError) throw error
throw new TestCaseError("压缩包损坏或不是 zip 格式")
}
// 只按「精确文件名」取内容,不遍历压缩包里的条目 ——
// 条目名一律不参与路径拼接,zip slip(`../../etc/passwd` 这类条目名)从设计上就进不来。
const names = new Set(
Object.keys(files).filter((name) => /^\d+\.(in|out|sql)$/.test(name)),
)
const names = new Set(Object.keys(files))
const selected = options.sql
? collectSqlScripts(names)