fix(游戏共创): 工程源包校验器对抗性补强(P0-a/b/c + 执行级交叉证据)
对抗性复核(`local://verify-m2b.md`)确认三个真实缺口,本次按 P0 → P1-b 补齐;两端规则逐条对齐,parity 门禁仍绿。 - **P0-a 凭据/依赖识别**:任意层级目录新增 `.aws` / `.ssh` / `.kube` / `.docker` / `.gnupg` / `.terraform` / `.secrets`(服务端新增独立变体 `CredentialDirectoryNotAllowed`);文件名前缀新增 `credentials` / `id_ecdsa` / `id_dsa` / `terraform.tfstate` / `service-account`;后缀新增 `.jks` / `.keystore` / `.ppk` / `.p8` / `.kdbx` / `.der`;全名新增 `.htpasswd` / `.pgpass`。另外对**小体积文本条目**(≤256 KiB、扩展名在白名单或无扩展名)做内容特征扫描:PEM 私钥块、`AKIA`+16 位、`ghp_`/`github_pat_`/`xox*-`(服务端新增 `SecretContentDetected`);更宽的前缀启发(如 `sk-`)**故意不做**并在注释里写明理由(误报会直接阻断作者发布) - **P0-b 规模检查不再被声明值骗**:两侧校验器改为**读取层封顶**——`by_index(index)?.take(declared_size + 1)` + 预分配收紧到单文件硬上限 + 多读 1 字节即判溢出、短读同样失败;压缩比分母从整包字节改为**该条目自身的压缩字节**;**同一修法同步到发行包校验器**(`package.rs` 原有同款洞),常量注释改成与实现一致(明确它不是整包炸弹防线) - **P0-c 嵌套包识别**:扩展名并集 `.zip/.tar/.gz/.tgz/.7z/.rar/.jar/.whl/.nupkg` + **magic bytes 嗅探**(zip/7z/rar/gzip/tar-ustar),改名成 `.dat` 也拦。**一处有证据的偏离**:`by_index_raw` 拿到的是**压缩流**(zip 2.4.2 文档与实测一致),按它嗅探会漏拦,因此改为对**已按声明大小封顶读出的解压内容**判 magic;客户端命中即报错并点名路径与格式 - **P1-b 执行级交叉证据(走 dev-dependency 路,未退化到 fixture)**:客户端 crate 加 `[dev-dependencies] module-game-distribution`,新测试用真实打包器产出字节喂给服务端 `validate_project_bundle_zip` 断言接受(并逐项比对摘要/字节数/条目顺序),再手造含 `.env` 与 `node_modules` 的包断言服务端独立拒绝 - 客户端同时补上**路径形状检查**(`BUNDLE_FORBIDDEN_PATH_CHARS` / `_SUFFIX_CHARS` / `_SEGMENTS` + `bundle_entry_path_shape_error`),修掉「macOS/Linux 上 `src/a?.ts`、`x.` 客户端能打、服务端必 422」的不一致;客户端压缩比预检也改按条目压缩字节 - 测试:`cargo test -p module-game-distribution` **73 passed**(+4);AGC `cargo test -- project_bundle` **24 passed**(含交叉证据那条);**发行包回归**:`cargo test -p api-server game_distribution` **60 passed / 0 failed**(`package.rs` 改动无行为回归,新增「声明说谎 → ReadFailed」用例) - 门禁:`cargo check --all-targets` 0;两端 `cargo fmt --check` 0;policy parity 0(服务端 51 条全被客户端覆盖);DTO parity 0;schema 0;encoding 0;`git diff --check` 0
This commit is contained in:
+20
@@ -1767,6 +1767,7 @@ dependencies = [
|
||||
"jsonschema",
|
||||
"libc",
|
||||
"maud",
|
||||
"module-game-distribution",
|
||||
"nalgebra",
|
||||
"oxc_allocator",
|
||||
"oxc_ast",
|
||||
@@ -2890,6 +2891,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-game-distribution"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"serde",
|
||||
"sha2",
|
||||
"shared-kernel",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
@@ -5000,6 +5012,14 @@ dependencies = [
|
||||
"ts-rs 12.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared-kernel"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"time",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_library"
|
||||
version = "0.1.9"
|
||||
|
||||
@@ -82,6 +82,11 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
maud = "0.27.0"
|
||||
|
||||
# P1-b 执行级交叉证据:客户端打包器产出的字节直接喂给服务端校验器
|
||||
# (`module-game-distribution::validate_project_bundle_zip`),替代只有文本级的一致性门禁。
|
||||
[dev-dependencies]
|
||||
module-game-distribution = { path = "../../../server-rs/crates/module-game-distribution" }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,11 @@ pub const MAX_PACKAGE_BYTES: u64 = 200 * 1024 * 1024;
|
||||
pub const MAX_EXPANDED_BYTES: u64 = 500 * 1024 * 1024;
|
||||
pub const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
|
||||
pub const MAX_FILE_COUNT: usize = 10_000;
|
||||
/// 单条目压缩比上限:**声明解压大小 / 该条目自身的压缩字节** 的倍数。
|
||||
///
|
||||
/// 分母是该条目自己的压缩字节,不是整包体积:整包分母下,一个 10 MiB 的包单条可以声明
|
||||
/// 1 GiB 而不触发,这条检查几乎不生效。整包维度的主约束由 `MAX_PACKAGE_BYTES` /
|
||||
/// `MAX_FILE_BYTES` / `MAX_EXPANDED_BYTES` 承担。
|
||||
pub const MAX_COMPRESSION_RATIO: u64 = 100;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -74,7 +79,7 @@ pub fn validate_release_zip(bytes: &[u8]) -> Result<ReleasePackageManifest, Rele
|
||||
let mut expanded_bytes = 0_u64;
|
||||
let mut has_entry = false;
|
||||
for index in 0..archive.len() {
|
||||
let mut file = archive
|
||||
let file = archive
|
||||
.by_index(index)
|
||||
.map_err(|_| ReleasePackageError::InvalidArchive)?;
|
||||
if file.encrypted() {
|
||||
@@ -110,12 +115,23 @@ pub fn validate_release_zip(bytes: &[u8]) -> Result<ReleasePackageManifest, Rele
|
||||
if expanded_bytes > MAX_EXPANDED_BYTES {
|
||||
return Err(ReleasePackageError::ExpandedPackageTooLarge);
|
||||
}
|
||||
if declared_size > package_bytes.saturating_mul(MAX_COMPRESSION_RATIO) {
|
||||
if declared_size > file.compressed_size().saturating_mul(MAX_COMPRESSION_RATIO) {
|
||||
return Err(ReleasePackageError::CompressionRatioTooHigh);
|
||||
}
|
||||
|
||||
let mut content = Vec::with_capacity(usize::try_from(declared_size).unwrap_or(0));
|
||||
file.read_to_end(&mut content)
|
||||
// 读取层按**声明大小**封顶:`take(declared + 1)` 保证这条条目最多产出
|
||||
// `declared + 1` 字节——读满声明值再多 1 字节即判「声明说谎(少报展开大小)」,
|
||||
// 短读(实际不足声明值)同样失败。预分配收紧到单文件硬上限。
|
||||
//
|
||||
// 不变式:单条实际解压内存 ≤ 其声明大小 + 1(声明大小已被 `MAX_FILE_BYTES` 与
|
||||
// `MAX_EXPANDED_BYTES` 夹住),因此整包实际内存被两道上限约束,不会出现
|
||||
// 「声明 1 KiB、实际解压数 GiB」的内存放大。
|
||||
let mut reader = file.take(declared_size.saturating_add(1));
|
||||
let mut content = Vec::with_capacity(
|
||||
usize::try_from(declared_size.min(MAX_FILE_BYTES)).unwrap_or(usize::MAX),
|
||||
);
|
||||
reader
|
||||
.read_to_end(&mut content)
|
||||
.map_err(|_| ReleasePackageError::ReadFailed)?;
|
||||
if u64::try_from(content.len()).unwrap_or(u64::MAX) != declared_size {
|
||||
return Err(ReleasePackageError::ReadFailed);
|
||||
@@ -297,4 +313,54 @@ mod tests {
|
||||
assert_eq!(manifest.package_bytes, bytes.len() as u64);
|
||||
assert_eq!(manifest.files.len(), 3);
|
||||
}
|
||||
|
||||
/// deflate 零内容条目,用于低成本构造规模类用例。
|
||||
fn zeros_archive(path: &str, size: u64) -> Vec<u8> {
|
||||
let mut output = Cursor::new(Vec::new());
|
||||
let mut writer = ZipWriter::new(&mut output);
|
||||
writer
|
||||
.start_file(path, SimpleFileOptions::default())
|
||||
.expect("zip entry");
|
||||
let chunk = vec![0_u8; 1024 * 1024];
|
||||
let mut remaining = size;
|
||||
while remaining > 0 {
|
||||
let take = usize::try_from(remaining.min(chunk.len() as u64)).unwrap_or(chunk.len());
|
||||
writer.write_all(&chunk[..take]).expect("zip content");
|
||||
remaining -= take as u64;
|
||||
}
|
||||
writer.finish().expect("finish zip");
|
||||
output.into_inner()
|
||||
}
|
||||
|
||||
/// 把单条目 zip 声明的解压大小改成 `declared_size`,但不动实际 deflate 数据:
|
||||
/// 构造「声明说谎」的发行包(本地文件头与中央目录项一起改)。
|
||||
fn declared_size_lie_archive(path: &str, actual_size: u64, declared_size: u32) -> Vec<u8> {
|
||||
let mut bytes = zeros_archive(path, actual_size);
|
||||
assert_eq!(&bytes[0..4], b"PK\x03\x04", "local file header");
|
||||
// 本地文件头:… crc(14) + compressed(18) → 解压大小在偏移 22。
|
||||
bytes[22..26].copy_from_slice(&declared_size.to_le_bytes());
|
||||
let central = bytes
|
||||
.windows(4)
|
||||
.position(|window| window == b"PK\x01\x02")
|
||||
.expect("central directory header");
|
||||
// 中央目录项:… crc(16) + compressed(20) → 解压大小在偏移 24。
|
||||
bytes[central + 24..central + 28].copy_from_slice(&declared_size.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// P0-b(发行包路径的回归证据):声明说谎必须在读取层失败关闭,不能先把 deflate
|
||||
/// 流整段解进内存再比对长度。
|
||||
#[test]
|
||||
fn rejects_entries_whose_declared_size_is_a_lie() {
|
||||
let under_report = declared_size_lie_archive("assets/liar.bin", 8 * 1024 * 1024, 1024);
|
||||
assert_eq!(
|
||||
validate_release_zip(&under_report),
|
||||
Err(ReleasePackageError::ReadFailed)
|
||||
);
|
||||
let over_report = declared_size_lie_archive("assets/liar.bin", 4096, 1500);
|
||||
assert_eq!(
|
||||
validate_release_zip(&over_report),
|
||||
Err(ReleasePackageError::ReadFailed)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user