test(游戏共创): 工程源包 e2e 补对抗用例(路径/凭据/嵌套包/符号链接/声明说谎)
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- 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
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user