From b105fe3cbdafd2deea08704dd96a5fb9cc5d2ad7 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 27 Sep 2026 15:14:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E6=B5=8B=E8=AF=95=E7=82=B9):=20=E8=A7=A3?= =?UTF-8?q?=E5=8E=8B=E5=89=8D=E6=8C=89=E6=9D=A1=E7=9B=AE=E5=A4=B4=E6=8B=A6?= =?UTF-8?q?=E5=A4=A7=E5=B0=8F=EF=BC=8C=E9=98=B2=20zip=20bomb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来 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 --- apps/api/src/services/test-case.ts | 45 ++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/api/src/services/test-case.ts b/apps/api/src/services/test-case.ts index 618e293..bced12d 100644 --- a/apps/api/src/services/test-case.ts +++ b/apps/api/src/services/test-case.ts @@ -77,6 +77,38 @@ function collectSqlScripts(names: Set) { 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 { let files: Record 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)