From d0c0ca5033b36725e889bfc670f6299f9702d217 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 17:23:37 +0800 Subject: [PATCH] =?UTF-8?q?test(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20?= =?UTF-8?q?=E5=B7=A5=E7=A8=8B=E6=BA=90=E5=8C=85=20e2e=20=E8=A1=A5=E5=AF=B9?= =?UTF-8?q?=E6=8A=97=E7=94=A8=E4=BE=8B=EF=BC=88=E8=B7=AF=E5=BE=84/?= =?UTF-8?q?=E5=87=AD=E6=8D=AE/=E5=B5=8C=E5=A5=97=E5=8C=85/=E7=AC=A6?= =?UTF-8?q?=E5=8F=B7=E9=93=BE=E6=8E=A5/=E5=A3=B0=E6=98=8E=E8=AF=B4?= =?UTF-8?q?=E8=B0=8E=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/check-game-distribution-project-bundle-e2e.mjs 新增 A7 对抗段(脚本内自带裸 ZIP 写手, 可逐字节控制条目名、unix mode 与 central directory 声明值),逐条断言 422 + details.reason: · A7-1 路径穿越:foo/../bar、/etc/passwd、C:/evil.txt、a\..\b、./x、a//b → InvalidPath · A7-2 路径形状:a/secret.、a/secret(尾随空格)、src/a?.ts、a*b.ts → InvalidPath · A7-3 大小写变体:Node_Modules/… → DependencyDirectoryNotAllowed;.GIT/HEAD → VersionControlDirectoryNotAllowed;.AGENT/x → LocalStateDirectoryNotAllowed · A7-4 符号链接条目(external attrs=0o120777)→ SymlinkNotAllowed · A7-5 嵌套包改名 deps.dat(zip magic)→ NestedArchiveNotAllowed · A7-6 凭据:.aws/credentials、.ssh/id_ecdsa → CredentialDirectoryNotAllowed;.htpasswd、service-account-prod.json、terraform.tfstate、app.p8 → SensitiveFileNotAllowed · A7-7 内容嗅探:无扩展名文件里的 PEM 私钥块 → SecretContentDetected · A7-8 声明说谎:STORE 条目声明 1 字节、实际 4096 字节 → ReadFailed(失败关闭;非内存量测) · A7 收尾:全部拒绝后该版本仍 bytes=0/sha256 空;对照包(.dat/文本/无扩展名/PNG)200 不被过度拦截 - 修正一处我此前的错误判断:`./x`/`a//b` 并非「被 zip crate 归一化后放行」,而是被校验器自身的 路径形状检查(空段/`.`)拒为 InvalidPath(zip-2.4.2 types.rs:537-555 只拒 NUL/根/`..` 逃逸, 且返回未归一化原串);脚本 NOTE 已按实测与源码改正,两条也改为严格 422 断言 - 实测:本地 dev 栈(SpacetimeDB 3110 / api-server 8188)72 项 72 PASS / 0 FAIL / 0 SKIP; 同栈回归 lineage-e2e 99/99(真实发行包上传,覆盖 package.rs 读取封顶改动)与 fork-authorization-e2e 48/48 --- ...k-game-distribution-project-bundle-e2e.mjs | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) diff --git a/scripts/check-game-distribution-project-bundle-e2e.mjs b/scripts/check-game-distribution-project-bundle-e2e.mjs index a1090ca91..f3ca9d8b0 100644 --- a/scripts/check-game-distribution-project-bundle-e2e.mjs +++ b/scripts/check-game-distribution-project-bundle-e2e.mjs @@ -271,6 +271,135 @@ async function buildProjectBundle({ }; } +// ---------- 对抗用例用的裸 ZIP 写手 ---------- +// +// JSZip 会规范化条目名、且不便于构造「符号链接条目」「伪造声明大小」这类畸形包, +// 对抗用例需要一个能逐字节控制 local header / central directory 的写手。 +// 只用 STORE(method=0、无 data descriptor),格式见 PKWARE APPNOTE: +// local header 0x04034b50 / central 0x02014b50 / EOCD 0x06054b50。 + +const CRC32_TABLE = (() => { + const table = new Uint32Array(256); + for (let index = 0; index < 256; index += 1) { + let value = index; + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + table[index] = value >>> 0; + } + return table; +})(); + +function crc32(buffer) { + let crc = 0xffffffff; + for (const byte of buffer) { + crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +/// entries: `{ name, data, mode?, versionMadeBy?, declaredUncompressedSize? }` +/// - `mode`:unix mode,写进 central directory 的高 16 位(0o120777 = 符号链接)。 +/// - `declaredUncompressedSize`:故意与真实字节数不一致,用于「声明说谎」用例。 +function buildRawZip(entries) { + const locals = []; + const centrals = []; + let offset = 0; + for (const entry of entries) { + const nameBytes = Buffer.from(entry.name, 'utf8'); + const data = Buffer.from(entry.data ?? ''); + const crc = crc32(data); + const declared = entry.declaredUncompressedSize ?? data.length; + const versionMadeBy = entry.versionMadeBy ?? 0x031e; // 3.0 / unix + const externalAttrs = ((entry.mode ?? 0o100644) & 0xffff) << 16; + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); // version needed + local.writeUInt16LE(0, 6); // flags + local.writeUInt16LE(0, 8); // method: STORE + local.writeUInt16LE(0, 10); // time + local.writeUInt16LE(0, 12); // date + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); // compressed size + local.writeUInt32LE(declared, 22); // uncompressed size (可被伪造) + local.writeUInt16LE(nameBytes.length, 26); + local.writeUInt16LE(0, 28); // extra length + locals.push(local, nameBytes, data); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(versionMadeBy, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0, 8); + central.writeUInt16LE(0, 10); + central.writeUInt16LE(0, 12); + central.writeUInt16LE(0, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(declared, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt16LE(0, 30); // extra + central.writeUInt16LE(0, 32); // comment + central.writeUInt16LE(0, 34); // disk + central.writeUInt16LE(0, 36); // internal attrs + central.writeUInt32LE(externalAttrs >>> 0, 38); + central.writeUInt32LE(offset, 42); + centrals.push(central, nameBytes); + + offset += local.length + nameBytes.length + data.length; + } + const centralSize = centrals.reduce((sum, part) => sum + part.length, 0); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); + eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(entries.length, 8); + eocd.writeUInt16LE(entries.length, 10); + eocd.writeUInt32LE(centralSize, 12); + eocd.writeUInt32LE(offset, 16); + eocd.writeUInt16LE(0, 20); + const bytes = Buffer.concat([...locals, ...centrals, eocd]); + return { + bytes, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; +} + +/// 对抗用例的每次上传都只带一个「合法基线条目 + 一个可疑条目」, +/// 这样 422 只能归因于可疑条目本身。 +function adversarialZip(caseName, data = Buffer.from('x'), extra = {}) { + return buildRawZip([ + { name: 'package.json', data: Buffer.from('{"name":"adversarial-e2e"}') }, + { name: caseName, data, ...extra }, + ]); +} + +/// 422 断言:错误码必须是 PROJECT_BUNDLE_VALIDATION_FAILED,并把 details.reason 打出来。 +async function expectBundleRejected(label, versionId, bytes, expectedReasons) { + // HTTP 头必须是 ByteString:把中文标签折成 ASCII 键片段。 + const keyTag = label.replace(/[^A-Za-z0-9]+/gu, '-').slice(0, 48); + const response = await putProjectBundle(versionId, bytes, { + token: authorTokenRef.token, + key: `pb-adv-${keyTag}-${stampRef.value}`, + }); + const reason = response.error?.details?.reason ?? ''; + const codeMatches = + response.status === 422 && + (response.error?.code ?? '') === 'PROJECT_BUNDLE_VALIDATION_FAILED'; + const reasonMatches = expectedReasons.includes(reason); + check( + `${label} → 422 PROJECT_BUNDLE_VALIDATION_FAILED(reason=${reason || '∅'})`, + codeMatches && reasonMatches, + `${brief(response)} expectedReason∈[${expectedReasons.join(',')}]`, + ); + return response; +} + +// 供上面的 helper 使用(main 里赋值,避免把 token/stamp 一路透传)。 +const authorTokenRef = { token: '' }; +const stampRef = { value: 0 }; + // ---------- 上行 ---------- async function putProjectBundle(versionId, bytes, { token, key }) { @@ -470,6 +599,7 @@ async function main() { const stamp = Date.now(); const suffix = String(stamp).slice(-6); + stampRef.value = stamp; const author = await register('132'); const other = await register('133'); check( @@ -483,6 +613,7 @@ async function main() { `status=${other.response.status} phone=${other.phone}`, ); if (!author.token || !other.token) process.exit(1); + authorTokenRef.token = author.token; // ---------- fixture:作品 A ---------- const gameTitle = `工程源包 ${suffix}`; @@ -583,6 +714,181 @@ async function main() { `bytes=${v2After.version?.projectBundleBytes} sha256=${JSON.stringify(v2After.version?.projectBundleSha256 ?? null)}`, ); + // ---------- A7:对抗用例(校验器补强后的拒绝清单) ---------- + // 预期形状读自实现:module-game-distribution/src/project_bundle.rs(目录/凭据/嵌套包 + // 拒绝清单 :235-295、magic 嗅探 :326-345、凭据内容嗅探 :360-400)与共享路径规范化 + // server-rs/crates/module-game-distribution/src/package.rs:158-182。 + const vAdv = await createVersion({ + token: author.token, + gameId, + metadata, + zip: await buildReleaseZip(`vadv-${suffix}`), + stamp, + tag: 'vadv', + }); + const vAdvId = vAdv.data?.versionId; + check( + 'A7 对抗用例版本创建成功(awaiting_upload)', + vAdv.status === 200 && Boolean(vAdvId), + `status=${vAdv.status} versionId=${vAdvId ?? ''}`, + ); + if (!vAdvId) process.exit(1); + + // A7-1 路径穿越/畸形路径(全部 422 InvalidPath) + for (const name of [ + 'foo/../bar', + '/etc/passwd', + 'C:/evil.txt', + 'a\\..\\b', + './x', + 'a//b', + ]) { + await expectBundleRejected( + `A7 路径穿越 ${name}`, + vAdvId, + adversarialZip(name).bytes, + ['InvalidPath'], + ); + } + note( + '两层防线共同覆盖这些形状:zip crate 的 enclosed_name() 只拒 NUL / 根路径 / `..` 逃逸' + + '(zip-2.4.2 src/types.rs:537-555,且返回的是**未归一化**的原始条目名);' + + '`a//b`、`./x`、结尾点/空格、`:`/`*`/`?`、反斜杠等由校验器自己的路径形状检查逐段拦下' + + '(module-game-distribution/src/package.rs:158-182:空段 / `.` / `..` / 尾随空格或点 / 禁止字符 / 反斜杠)。' + + '实测这六种形状全部 422 + reason=InvalidPath。', + ); + + // A7-2 结尾点/空格与禁止字符 + for (const name of ['a/secret.', 'a/secret ', 'src/a?.ts', 'a*b.ts']) { + await expectBundleRejected( + `A7 路径形状 ${name}`, + vAdvId, + adversarialZip(name).bytes, + ['InvalidPath'], + ); + } + + // A7-3 大小写变体目录(拒绝清单按大小写折叠比对) + await expectBundleRejected( + 'A7 大小写变体 Node_Modules/lodash/x.js', + vAdvId, + adversarialZip('Node_Modules/lodash/x.js').bytes, + ['DependencyDirectoryNotAllowed'], + ); + await expectBundleRejected( + 'A7 大小写变体 .GIT/HEAD', + vAdvId, + adversarialZip('.GIT/HEAD').bytes, + ['VersionControlDirectoryNotAllowed'], + ); + await expectBundleRejected( + 'A7 大小写变体 .AGENT/x', + vAdvId, + adversarialZip('.AGENT/x').bytes, + ['LocalStateDirectoryNotAllowed'], + ); + + // A7-4 符号链接条目(central directory 高 16 位 = 0o120777) + await expectBundleRejected( + 'A7 符号链接条目', + vAdvId, + adversarialZip('link-outside', Buffer.from('/etc/passwd'), { + mode: 0o120777, + }).bytes, + ['SymlinkNotAllowed'], + ); + + // A7-5 嵌套包:改名成 deps.dat,只能靠 magic bytes 拦住 + const innerZip = buildRawZip([ + { name: 'node_modules/x/index.js', data: Buffer.from('x') }, + ]); + await expectBundleRejected( + 'A7 嵌套 zip 改名 deps.dat', + vAdvId, + adversarialZip('deps.dat', innerZip.bytes).bytes, + ['NestedArchiveNotAllowed'], + ); + + // A7-6 凭据类(补强新增的目录/文件名规则) + const credentialCases = [ + [ + '.aws/credentials', + ['CredentialDirectoryNotAllowed', 'SensitiveFileNotAllowed'], + ], + [ + '.ssh/id_ecdsa', + ['CredentialDirectoryNotAllowed', 'SensitiveFileNotAllowed'], + ], + ['.htpasswd', ['SensitiveFileNotAllowed']], + ['service-account-prod.json', ['SensitiveFileNotAllowed']], + ['terraform.tfstate', ['SensitiveFileNotAllowed']], + ['app.p8', ['SensitiveFileNotAllowed']], + ]; + for (const [name, reasons] of credentialCases) { + await expectBundleRejected( + `A7 凭据 ${name}`, + vAdvId, + adversarialZip(name).bytes, + reasons, + ); + } + + // A7-7 凭据内容嗅探:无扩展名文件里放 PEM 私钥块 + await expectBundleRejected( + 'A7 内容嗅探(无扩展名 PEM 私钥)', + vAdvId, + adversarialZip( + 'secrets', + Buffer.from('-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n'), + ).bytes, + ['SecretContentDetected'], + ); + + // A7-8 声明说谎:STORE 条目把解锁声明写成 1 字节、实际 4096 字节 + await expectBundleRejected( + 'A7 声明说谎(声明 1 字节 / 实际 4096 字节)', + vAdvId, + adversarialZip('liar.bin', Buffer.alloc(4096, 0x41), { + declaredUncompressedSize: 1, + }).bytes, + ['ReadFailed', 'InvalidArchive'], + ); + note( + '声明说谎用例断言的是「失败关闭」而不是内存量测:服务端按声明大小 take(declared+1) 读取' + + '(package.rs:115-135),多读 1 字节即判 ReadFailed,因此不会出现「声明 1 KiB、实际解压数 GiB」的放大;' + + '服务端真实内存占用本脚本无法观测(未量测 RSS)。', + ); + + // 全部拒绝用例之后:该版本仍未落库 + const vAdvAfter = await ownerVersion(author.token, vAdvId); + check( + 'A7 全部拒绝用例后该版本仍 bytes=0 / sha256 空', + (vAdvAfter.version?.projectBundleBytes ?? -1) === 0 && + !vAdvAfter.version?.projectBundleSha256, + `bytes=${vAdvAfter.version?.projectBundleBytes} sha256=${JSON.stringify(vAdvAfter.version?.projectBundleSha256 ?? null)}`, + ); + + // A7-9 过度拦截对照:普通 .dat / 文本 / 无扩展名 / 二进制条目必须放行 + const controlBundle = await buildProjectBundle({ + marker: `adv-control-${suffix}`, + extraFiles: { + 'notes.dat': 'plain text payload\n', + 'docs/readme.md': '# readme\n', + 'notes.txt': 'notes\n', + LICENSE: 'MIT\n', + 'assets/logo.png': COVER_PNG, + }, + }); + const controlPut = await putProjectBundle(vAdvId, controlBundle.bytes, { + token: author.token, + key: `pb-adv-control-${stamp}`, + }); + check( + 'A7 对照:普通 .dat/文本/无扩展名/二进制条目不被过度拦截(200)', + controlPut.status === 200, + brief(controlPut), + ); + // ---------- A2:>8 MiB 不可压缩负载走两片 ---------- const bigRandomBytes = 9 * 1024 * 1024; // 9 MiB 随机字节:压缩后仍 >8 MiB const bigBundle = await buildProjectBundle({