补齐游戏分发跨账号越权用例并统一送审的 404 口径
Project CI / AI game creator shell Rust crates (push) Successful in 1m40s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m9s
Project CI / Backend tests (push) Successful in 5m4s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled

- 新增 scripts/check-game-distribution-owner-isolation.mjs:对本地真实栈注册两个账号,逐条验证私有版本读取、上传状态、分片写入、分包确认、送审、撤回与未认证访问的越权口径,并验证请求体伪造 owner 不生效
- api-server 的 submit_version 改用 load_owner_version_or_404:他人送审返回 404 而不是 403,不再用错误码泄露版本是否存在
- package.json 注册 check:game-distribution-owner-isolation
- 游戏分发里程碑阶段 A 第 1 条改为已勾选,写入 21 项 PASS 取证与修复前的 403 变异证据
This commit is contained in:
kdletters
2026-09-28 18:23:52 +08:00
parent 4c4e61255e
commit 0989f9f40e
4 changed files with 451 additions and 8 deletions
@@ -48,7 +48,7 @@
### 行为与验收
- [ ] 登录用户创建服务端分配的游戏,owner 不能由请求伪造;其他账号不能读取私有版本、上传、提交或撤销。
- [x] 登录用户创建服务端分配的游戏,owner 不能由请求伪造;其他账号不能读取私有版本、上传、提交或撤销。
- [x] 服务端接收真实 ZIP 字节,重算摘要/字节数并建立展开清单;只有 metadata 的请求不能获得已上传或已发布状态。
- [x] 缺入口、越界/重复/大小写冲突路径、符号链接、压缩炸弹、敏感内容和额度超限均失败关闭,原私有对象和公开状态保持一致。
- [ ] 一份版本只接受一份已确认内容;同 key 同请求重放无重复游戏/版本,不同请求冲突;同版本并发上传不混写。
@@ -159,8 +159,11 @@
- 校验侧:`rejects_missing_entry_sensitive_and_traversal_paths`(缺入口 / 敏感文件 / 越界路径 / 重复与大小写冲突,见 `package.rs` 的 `case_folded_paths`)、`rejects_symlink_entries`(**本轮新增**:`unix_permissions` 会把 mode 掩成 `0o777`、正常写入路径补的是 `S_IFREG`,所以只能用 `ZipWriter::add_symlink` 造真实 `S_IFLNK` 条目;变异去掉 `is_symlink` 守卫后只有该用例变红)、`keeps_expansion_headroom_over_package_limit`、`accepts_package_above_the_previous_hundred_mib_limit`、`asset_path_rejects_traversal_and_absolute_input`、`content_type_allowlist_fails_closed_for_unknown_extensions`、`extraction_only_returns_the_exact_declared_entry`。
- 「额度超限」按现行实现口径=包 / 单文件 / 展开量 / 条目数 / 压缩比上限(`MAX_PACKAGE_BYTES` 200 MiB、`MAX_FILE_BYTES` 64 MiB、`MAX_EXPANDED_BYTES` 500 MiB、`MAX_FILE_COUNT` 10 000、压缩比 100:1),由上面两条上限用例与 api-server 的 `package_request_body_limit_covers_max_package_bytes` 覆盖;这条链路**没有按用户配额**(本机检索确认仓库里的配额实现只出现在客户端项目快照里),因此"额度超限"按上限口径验收。
- 「失败关闭后原私有对象和公开状态保持一致」:**本轮新增** `failed_validation_keeps_the_previous_publication_and_confirmed_package`——第二版校验失败后,已公开游戏的快照与上一版已确认的包身份必须逐字段不变;变异成"每次版本状态变化都顺带碰一下游戏记录"后只有该用例变红。
- 已勾选(第 1 条:服务端分配游戏身份、owner 不能由请求伪造、跨账号读/写/送审/撤回全部拒绝)——本轮补齐
- **本轮新增** `scripts/check-game-distribution-owner-isolation.mjs`(`npm run check:game-distribution-owner-isolation`):对本地真实栈(SpacetimeDB `xushi-p4wfr` @ 127.0.0.1:3101 + api-server @ 127.0.0.1:4198,发布灰度按既有 E2E 口径临时开启后恢复关闭)注册两个真实账号,作者建游戏与版本后逐条验证越权:读私有版本 404、读上传状态 404、写分片 404、确认分包 404、送审 404、撤回 404、未认证读取 401;越权写之后作者侧 `receivedBytes` 仍为 0(越权请求没有落任何副作用);请求体里塞 `ownerUserId` / `owner_user_id` 的伪造游戏仍只出现在请求者自己的 `my-games`、不出现在被冒名账号的 `my-games`;作者撤回自己的版本得到 `cancelled`。**21 项全部 PASS**。
- **本轮顺带修掉一处真实缺陷**:`submit_version` 之前直接调 `get_owner_game_distribution_version`,owner 不匹配被映射成 403,而读版本/上传状态/分片/确认/撤回都按 404 处理——同一条「不属于当前主体」的语义出现两种错误码,送审入口会泄露版本是否存在。改成复用 `load_owner_version_or_404` 后,他人送审作者版本与送审不存在的版本都返回 404;脚本里这两条断言分别覆盖,修复前前者是 403 `FORBIDDEN` 让用例变红。
- 边界:脚本会为「建游戏必须提供封面」写一个 67 字节 PNG 到 dev bucket(走现役直传 + 确认链路);发行包分片与确认全部停在越权拒绝之前,不产生任何对象。
- 仍未勾选(缺口写具体,避免"看起来做了")
- 第 1 条(owner 不能由请求伪造;其他账号不能读取私有版本、上传、提交或撤销):领域侧有 `owner_is_required_for_version_and_package_mutations`;接口侧代码在越权时返回 403/404,但**没有跨账号读/写/撤销的接口级用例**——缺的就是这一层。
- 第 4 条(一份版本只接受一份已确认内容;同 key 同请求幂等、不同请求冲突;同版本并发上传不混写):前两半已覆盖——`idempotency_replays_same_snapshot_and_rejects_digest_conflict`、`validation_failure_can_retry_same_confirmed_package`、api-server 的 `idempotency_key_requires_a_bounded_non_empty_header`;**本轮新增** `a_version_accepts_only_one_confirmed_package`(不同摘要或字节数的确认被拒为 `PackageMismatch`,重复确认同一份内容返回同一包身份;变异去掉该守卫后只有它变红)。**仍缺**「同版本并发上传不混写」:串行化在 `spacetime-module` / api-server 的 CAS 那一层,领域服务本身是同步的,需要在那一层写用例。
- 第 5 条(校验可异步恢复;响应丢失、服务进程退出与客户端重试回到原版本;确定失败与未知结果可区分):只有 api-server 的 `recovery_action_covers_every_version_status` 覆盖"状态 → 恢复动作"的映射;后两句在 game-distribution 链路没有用例。
- 第 6 条(状态/私有查询/错误 envelope 的 Rust 与 TS DTO 一致;新增 schema、迁移、表目录与绑定一致):后半句有门禁(`npm run lint` 内的 SpacetimeDB schema guard 覆盖 85 张表、生成绑定校验通过)。**本轮新增** `check:game-distribution-dto-parity`(已接进 `npm run lint`):按显式映射表逐字段/逐变体比对 14 组 Rust `shared-contracts` DTO 与手写 `packages/shared/src/contracts/gameDistribution.ts`,两个方向都做过变异验证——TS 侧把 `name` 改成 `displayName`、Rust 侧给 `GameDistributionAuthor` 加 `extra_field`,各自都让门禁失败并指出缺哪个字段;脚本同时登记了 7 个「服务端逐字段手拼 JSON、没有 Rust 结构体」的 TS 类型。**本轮补齐** `coverObjectKey` / `screenshots` / `publicationRevision` 四个字段进 Rust 结构体(依据是 `game_payload` 与 `private_version_payload` 实际发出的键),并删掉脚本里用来豁免它们的 `TS_ONLY_FIELDS` 白名单:现在任一方向多出字段都会让门禁失败,作者侧响应省略 `currentVersion` 这一条差异改用 TS 可选字段描述。**本轮再补构建器一层**:门禁新增 `RESPONSE_BUILDERS`,解析 `server-rs/crates/api-server/src/modules/game_distribution.rs` 里 `game_payload` / `public_game_payload` / `private_version_payload` / `version_summary_payload` 的 `json!` 顶层键与顶层 `object.insert(…)`,逐键比对 TS 类型:发出的键必须都在类型里、类型的必需字段必须都发出、`public_game_payload` 还必须发出被 TS 标成可选的 `currentVersion`,`game_payload` 的 `author` / `deviceSupport` 两个嵌套字面量同样逐键比对。变异验证五种改法各自让门禁失败并指出具体键:删掉 `game_payload.publicationRevision`、删掉 `deviceSupport.touch`、把 `author.avatarUrl` 改名、把插入的 `currentVersion` 改名、给 `version_summary_payload` 加一个 TS 没有的键;恢复后通过。**剩余缺口**:门禁比对的是键而不是值的类型,也覆盖不到未登记的嵌套对象与 envelope——成功/失败 envelope 的字段由 TS 侧运行时守卫(`packages/shared/src/http.ts`、`src/services/apiClient.ts`)消费,要彻底类型化得先把这两个响应改成结构化构建。