From 0989f9f40e95b68d00ecbe545024b298aafefcc9 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:23:52 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=B8=B8=E6=88=8F=E5=88=86?= =?UTF-8?q?=E5=8F=91=E8=B7=A8=E8=B4=A6=E5=8F=B7=E8=B6=8A=E6=9D=83=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E5=B9=B6=E7=BB=9F=E4=B8=80=E9=80=81=E5=AE=A1=E7=9A=84?= =?UTF-8?q?=20404=20=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 变异证据 --- ...‹碑】游戏分发目录详情与在线游玩-2026-09-18.md | 7 +- package.json | 1 + ...heck-game-distribution-owner-isolation.mjs | 441 ++++++++++++++++++ .../src/modules/game_distribution.rs | 10 +- 4 files changed, 451 insertions(+), 8 deletions(-) create mode 100644 scripts/check-game-distribution-owner-isolation.mjs diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index 27bc5f665..ee5282303 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -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`)消费,要彻底类型化得先把这两个响应改成结构化构建。 diff --git a/package.json b/package.json index fc3be4ecb..ad3a7cbe7 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "check:generated-bindings": "node scripts/check-generated-bindings.mjs", "check:game-distribution-dto-parity": "node scripts/check-game-distribution-dto-parity.mjs", "check:game-distribution-media-e2e": "node scripts/check-game-distribution-media-e2e.mjs", + "check:game-distribution-owner-isolation": "node scripts/check-game-distribution-owner-isolation.mjs", "check:production-ops": "node scripts/check-production-ops-guardrails.mjs", "check:preview-deployer": "node scripts/check-preview-deployer.mjs", "check:maintenance-page": "node scripts/check-maintenance-page.mjs", diff --git a/scripts/check-game-distribution-owner-isolation.mjs b/scripts/check-game-distribution-owner-isolation.mjs new file mode 100644 index 000000000..ad2b9a610 --- /dev/null +++ b/scripts/check-game-distribution-owner-isolation.mjs @@ -0,0 +1,441 @@ +// 游戏分发「作者归属与越权隔离」真实链路检查(需要本地 dev 栈:SpacetimeDB standalone + api-server)。 +// +// 用法: +// E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \ +// npm run check:game-distribution-owner-isolation +// E2E_API_BASE 可覆盖 api-server 地址(默认 http://127.0.0.1:4198)。 +// +// 覆盖:两个真实账号注册 → 作者建游戏与版本 → 另一个账号读私有版本、读上传状态、写分片、 +// 确认分包、送审、撤回全部按「不可见」处理 → 未认证读私有版本 401 → 越权写没有副作用 +// (作者侧已收字节仍为 0)→ 请求体里伪造 owner 不生效(游戏仍归请求者、不出现在被冒名账号 +// 的 my-games)→ 作者撤回自己的版本成功。整条链路只在本机 dev 数据库落行;唯一的存储写入 +// 是「建游戏必须提供封面」逼出的一个 67 字节 PNG(dev bucket,走现役直传 + 确认链路), +// 发行包分片与确认全部停在越权拒绝之前,不会产生任何对象。 +const COVER_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); +const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198'; +const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' }; +const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim(); +const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? ''; +const DEV_PASSWORD = 'GenE2e123!'; + +if (!ADMIN_USER || !ADMIN_PASSWORD) { + console.error( + '缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要按发布灰度口径打开 ' + + 'game-distribution:publish 写入口;本地栈可先以 GENARRATIVE_ADMIN_USERNAME / ' + + 'GENARRATIVE_ADMIN_PASSWORD 启动 api-server。', + ); + process.exit(2); +} + +let failures = 0; +function check(name, ok, detail = '') { + if (!ok) failures += 1; + console.log( + `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`, + ); +} + +async function api(path, options = {}) { + const { method = 'GET', token, body, headers = {}, binary } = options; + const finalHeaders = { ...ENVELOPE, ...headers }; + if (token) finalHeaders.Authorization = `Bearer ${token}`; + let finalBody; + if (binary) { + finalBody = binary; + } else if (body !== undefined) { + finalHeaders['Content-Type'] = 'application/json'; + finalBody = JSON.stringify(body); + } + const response = await fetch(`${API}${path}`, { + method, + headers: finalHeaders, + body: finalBody, + }); + const text = await response.text(); + let json = null; + try { + json = JSON.parse(text); + } catch { + json = null; + } + return { + status: response.status, + json, + text, + data: json?.data, + error: json?.error, + }; +} + +function gameMetadata(title) { + return { + title, + summary: '越权隔离用例的临时游戏', + description: '', + category: '休闲', + tags: ['e2e'], + deviceSupport: { desktop: true, mobile: false, touch: false }, + inputModes: ['keyboard', 'mouse'], + orientation: 'landscape', + }; +} + +async function uploadCover(token, id) { + const fileName = `owner-isolation-${id}.png`; + const ticket = await api('/api/assets/direct-upload-tickets', { + method: 'POST', + token, + body: { + legacyPrefix: 'generated-character-drafts', + pathSegments: ['game-distribution', 'owner-isolation', String(id)], + fileName, + contentType: 'image/png', + access: 'private', + maxSizeBytes: COVER_PNG.length, + metadata: { asset_kind: 'game_distribution_cover' }, + }, + }); + if (ticket.status !== 200) { + throw new Error( + `创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`, + ); + } + const upload = ticket.data.upload; + const form = new FormData(); + for (const [key, value] of Object.entries(upload.formFields ?? {})) { + if (value !== null && value !== undefined) form.append(key, String(value)); + } + form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName); + const put = await fetch(upload.host, { method: 'POST', body: form }); + if (!put.ok) { + throw new Error(`直传对象存储失败 ${put.status}`); + } + const confirm = await api('/api/assets/objects/confirm', { + method: 'POST', + token, + body: { + bucket: upload.bucket, + objectKey: upload.objectKey, + contentType: 'image/png', + contentLength: COVER_PNG.length, + assetKind: 'game_distribution_cover', + accessPolicy: 'private', + entityId: 'game-distribution-owner-isolation', + }, + }); + if (confirm.status !== 200) { + throw new Error( + `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, + ); + } + return confirm.data.assetObject.assetObjectId; +} + +async function register(prefix) { + const response = await api('/api/auth/entry', { + method: 'POST', + body: { + purePhoneNumber: `${prefix}${String(Date.now()).slice(-8)}`, + password: DEV_PASSWORD, + }, + }); + return { response, token: response.data?.token }; +} + +async function main() { + const adminLogin = await api('/admin/api/login', { + method: 'POST', + body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, + }); + const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken; + check( + '管理员登录成功', + adminLogin.status === 200 && Boolean(admin), + `status=${adminLogin.status}`, + ); + if (!admin) process.exit(1); + + const author = await register('137'); + const intruder = await register('138'); + check( + '作者注册拿到 token', + author.response.status === 200 && Boolean(author.token), + `status=${author.response.status}`, + ); + check( + '另一个账号注册拿到 token', + intruder.response.status === 200 && Boolean(intruder.token), + `status=${intruder.response.status}`, + ); + if (!author.token || !intruder.token) process.exit(1); + + const setGate = (enabled, rolloutPercent) => + api('/admin/api/feature-gates', { + method: 'PUT', + token: admin, + body: { + gateKey: 'game-distribution:publish', + enabled, + rolloutPercent, + allowUserIds: [], + allowUserTags: [], + denyUserIds: [], + description: 'E2E 作者归属隔离', + }, + }); + + const gateOpen = await setGate(true, 100); + check( + '发布灰度可开启并放量', + gateOpen.status === 200, + `status=${gateOpen.status}`, + ); + + const stamp = Date.now(); + const title = `越权隔离 ${String(stamp).slice(-6)}`; + const coverAssetId = await uploadCover(author.token, stamp); + const created = await api('/api/game-distribution/games', { + method: 'POST', + token: author.token, + headers: { 'Idempotency-Key': `iso-game-${stamp}` }, + body: { ...gameMetadata(title), coverAssetId }, + }); + const gameId = created.data?.id; + check( + '作者创建游戏成功', + created.status === 200 && Boolean(gameId), + `status=${created.status} msg=${created.error?.message ?? ''}`, + ); + if (!gameId) process.exit(1); + + const versionResponse = await api( + `/api/game-distribution/games/${gameId}/versions`, + { + method: 'POST', + token: author.token, + headers: { 'Idempotency-Key': `iso-version-${stamp}` }, + body: { + packageSha256: 'a'.repeat(64), + packageBytes: 2048, + packageFileCount: 2, + packageEntryPath: 'index.html', + gameMetadata: { ...gameMetadata(title), coverAssetId }, + }, + }, + ); + const versionId = versionResponse.data?.versionId; + const publicationRevision = versionResponse.data?.publicationRevision ?? 0; + check( + '作者创建版本成功(awaiting_upload)', + versionResponse.status === 200 && + Boolean(versionId) && + versionResponse.data?.status === 'awaiting_upload', + `status=${versionResponse.status} versionStatus=${versionResponse.data?.status}`, + ); + if (!versionId) process.exit(1); + + const ownRead = await api(`/api/game-distribution/versions/${versionId}`, { + token: author.token, + }); + check( + '作者读到自己的私有版本', + ownRead.status === 200 && ownRead.data?.version?.versionId === versionId, + `status=${ownRead.status}`, + ); + + const anonymousRead = await api( + `/api/game-distribution/versions/${versionId}`, + ); + check( + '未认证读私有版本 401', + anonymousRead.status === 401, + `status=${anonymousRead.status}`, + ); + + const foreignRead = await api( + `/api/game-distribution/versions/${versionId}`, + { token: intruder.token }, + ); + check( + '他人读私有版本按不存在处理(404)', + foreignRead.status === 404, + `status=${foreignRead.status} code=${foreignRead.error?.code ?? ''}`, + ); + + const foreignState = await api( + `/api/game-distribution/versions/${versionId}/package/upload-state`, + { token: intruder.token }, + ); + check( + '他人读上传状态 404', + foreignState.status === 404, + `status=${foreignState.status}`, + ); + + const foreignChunk = await api( + `/api/game-distribution/versions/${versionId}/package/chunk`, + { + method: 'PUT', + token: intruder.token, + headers: { + 'Content-Type': 'application/octet-stream', + 'Idempotency-Key': `iso-chunk-${stamp}`, + 'x-genarrative-upload-offset': '0', + }, + binary: Buffer.alloc(16, 7), + }, + ); + check( + '他人写发行包分片 404', + foreignChunk.status === 404, + `status=${foreignChunk.status}`, + ); + + const foreignComplete = await api( + `/api/game-distribution/versions/${versionId}/package/complete`, + { + method: 'POST', + token: intruder.token, + headers: { 'Idempotency-Key': `iso-complete-${stamp}` }, + }, + ); + check( + '他人确认分包 404', + foreignComplete.status === 404, + `status=${foreignComplete.status}`, + ); + + const foreignSubmit = await api( + `/api/game-distribution/versions/${versionId}/submit`, + { + method: 'POST', + token: intruder.token, + headers: { 'Idempotency-Key': `iso-submit-${stamp}` }, + body: { expectedPublicationRevision: publicationRevision }, + }, + ); + check( + '他人送审 404', + foreignSubmit.status === 404, + `status=${foreignSubmit.status} code=${foreignSubmit.error?.code ?? ''} msg=${foreignSubmit.error?.message ?? ''}`, + ); + + const nonexistentSubmit = await api( + `/api/game-distribution/versions/gamever_missing_${stamp}/submit`, + { + method: 'POST', + token: intruder.token, + headers: { 'Idempotency-Key': `iso-missing-submit-${stamp}` }, + body: { expectedPublicationRevision: 0 }, + }, + ); + check( + '他人送审不存在的版本 404(对照)', + nonexistentSubmit.status === 404, + `status=${nonexistentSubmit.status} code=${nonexistentSubmit.error?.code ?? ''}`, + ); + + const foreignCancel = await api( + `/api/game-distribution/versions/${versionId}/cancel`, + { + method: 'POST', + token: intruder.token, + headers: { 'Idempotency-Key': `iso-cancel-${stamp}` }, + body: { expectedPublicationRevision: publicationRevision }, + }, + ); + check( + '他人撤回 404', + foreignCancel.status === 404, + `status=${foreignCancel.status}`, + ); + + const authorState = await api( + `/api/game-distribution/versions/${versionId}/package/upload-state`, + { token: author.token }, + ); + check( + '越权写没有落副作用(作者侧已收字节仍为 0)', + authorState.status === 200 && authorState.data?.receivedBytes === 0, + `status=${authorState.status} receivedBytes=${authorState.data?.receivedBytes}`, + ); + + const intruderCoverAssetId = await uploadCover( + intruder.token, + `${stamp}-intruder`, + ); + const forged = await api('/api/game-distribution/games', { + method: 'POST', + token: intruder.token, + headers: { 'Idempotency-Key': `iso-forged-${stamp}` }, + body: { + ...gameMetadata(`伪造 owner ${String(stamp).slice(-6)}`), + coverAssetId: intruderCoverAssetId, + ownerUserId: 'user_forged', + owner_user_id: 'user_forged', + }, + }); + const forgedGameId = forged.data?.id; + check( + '请求体伪造 owner 不影响创建成功', + forged.status === 200 && Boolean(forgedGameId), + `status=${forged.status} msg=${forged.error?.message ?? ''}`, + ); + + const intruderGames = await api('/api/game-distribution/my-games', { + token: intruder.token, + }); + const forgedStaysWithRequester = + Array.isArray(intruderGames.data?.games) && + intruderGames.data.games.some((game) => game.id === forgedGameId); + check( + '伪造 owner 的游戏仍归请求者(在请求者 my-games 里)', + forgedStaysWithRequester, + `games=${intruderGames.data?.games?.length ?? 'n/a'}`, + ); + + const authorGames = await api('/api/game-distribution/my-games', { + token: author.token, + }); + const forgedLeakedToAuthor = + Array.isArray(authorGames.data?.games) && + authorGames.data.games.some((game) => game.id === forgedGameId); + check( + '伪造 owner 的游戏不出现在被冒名账号的 my-games', + !forgedLeakedToAuthor, + `authorGames=${authorGames.data?.games?.length ?? 'n/a'}`, + ); + + const ownCancel = await api( + `/api/game-distribution/versions/${versionId}/cancel`, + { + method: 'POST', + token: author.token, + headers: { 'Idempotency-Key': `iso-own-cancel-${stamp}` }, + body: { expectedPublicationRevision: publicationRevision }, + }, + ); + check( + '作者撤回自己的版本成功(cancelled)', + ownCancel.status === 200 && ownCancel.data?.version?.status === 'cancelled', + `status=${ownCancel.status} versionStatus=${ownCancel.data?.version?.status}`, + ); + + const gateClosed = await setGate(false, 0); + check( + '发布灰度恢复关闭', + gateClosed.status === 200, + `status=${gateClosed.status}`, + ); + + console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`); + process.exit(failures === 0 ? 0 : 1); +} + +main().catch((error) => { + console.error(`[check:game-distribution-owner-isolation] 运行失败:${error}`); + process.exit(1); +}); diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index ee797f8df..558f88c55 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -1173,12 +1173,10 @@ async fn submit_version( let owner_user_id = auth.claims().user_id().to_string(); ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; let idempotency_key = idempotency_key(&headers)?; - let version = state - .spacetime_client() - .get_owner_game_distribution_version(owner_user_id.clone(), version_id.clone()) - .await - .map_err(map_spacetime_error)? - .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; + // 与其它作者入口同口径:版本不存在或不属于当前主体都按 404 处理, + // 不能用 403 区分“别人的版本”,否则送审入口会泄露版本是否存在。 + let version = + load_owner_version_or_404(&state, owner_user_id.clone(), version_id.clone()).await?; let game = state .spacetime_client() .get_game_distribution_game(GameDistributionGetGameRecordInput {