diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index e7ceaee20..f4cc01664 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -188,4 +188,4 @@ - 已勾选(第 4 条:游客目录、详情和启动接口只返回已公开投影;未公开和已下架状态均不可见、不返回私有快照地址)——本轮补齐 - 待审期间公开目录不含该游戏、封面没有匿名读授权(第 1 条取证里已跑);**本轮**补下架半边:作者 `unpublish` 成功后公开目录不含该游戏、`GET /api/game-distribution/games/{id}` 返回 404、发行网关 `GET /api/game-distribution/releases/{id}/index.html` 返回 404,且目录与详情的响应文本里没有 `aliyuncs.com` / `oss-cn` / `Signature=` 这类对象存储地址。- 仍未勾选(阶段 B 其余条目) - 第 5/6 条(每游戏独立 HTTPS origin、iframe sandbox、CSP/CORS/MIME/禁止 Worker、外站请求阻断):需要真实域名与真实浏览器,本轮未做;api-server 侧只有同源网关单测(`release_gateway_is_mounted_and_never_serves_cookie_bearing_requests`、`release_response_sets_nosniff_and_scopes_csp_to_html`、`release_response_allows_opaque_sandbox_asset_loads`)。 - - 第 7 条(下架会关闭新启动与发行读取、不能绕过网关直取公开 OSS、撤销传播在最大缓存窗口内):前半句本轮已取证——作者 `unpublish` 与管理员 `suspend` 之后公开目录不含该游戏、详情 404、发行网关 404,`restore` 后重新可玩。**仍缺**:①「不能绕过网关直取公开 OSS」在当前环境**不成立**:匿名 `GET https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/project-snapshots/v1/game-distribution//.zip` 返回 **200**(同 bucket 不存在的 key 是 404),根因是 `platform-oss` 的 `put_internal_object_bytes` 收了 `OssObjectAccess` 却从不发送 `x-oss-object-acl`,对象继承 bucket 默认 ACL;脚本默认把这条打成 `WARN`,`E2E_REQUIRE_PRIVATE_BUCKET=1` 时转成硬失败(本轮两种模式都实测)。②「撤销传播符合最大缓存窗口」需要真实 CDN/TTL,本轮未做。 + - 第 7 条(下架会关闭新启动与发行读取、不能绕过网关直取公开 OSS、撤销传播在最大缓存窗口内):前半句本轮已取证——作者 `unpublish` 与管理员 `suspend` 之后公开目录不含该游戏、详情 404、发行网关 404,`restore` 后重新可玩。①「不能绕过网关直取公开 OSS」**本轮已修并复验**:先用同一条探测证明匿名直取发行包返回 200(同 bucket 不存在的 key 是 404),根因是 `platform-oss` 收了 `OssObjectAccess` 却从不发送 `x-oss-object-acl`、对象继承 bucket 默认 ACL;现在内部 PUT、分片追加(`OssAppendInternalObjectRequest` 新增 `access`)与直传 policy/表单(`DirectUploadTicketFormFields` 新增 `x-oss-object-acl`)都显式下发对象级 ACL,`E2E_REQUIRE_PRIVATE_BUCKET=1` 下匿名直取直传封面与发行包都返回 **403**(65 项 PASS)。②「撤销传播符合最大缓存窗口」需要真实 CDN/TTL,本轮未做。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6a75b464a..4a649649f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9696,5 +9696,5 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(确定失败与未知结果的分界):结构合法但摘要/体积/文件数/入口与声明不符 → `409 PACKAGE_MISMATCH`,版本落 `upload_failed` 且 `recoveryAction=reupload`;中断、进程退出与并发落败都停在原版本 `awaiting_upload`,靠 `upload-state` 的权威偏移继续,不落成失败态。 - 决策(错误 envelope 与成功 envelope 同一份 meta):`AppError::into_response` 之前固定按「无请求上下文」构造错误响应,错误 envelope 里没有 `meta.requestId` / `meta.operation`,而成功 envelope 有,客户端在报错时拿不到可用于排查的 requestId。现在 `attach_request_context` 用 `tokio::task_local!` 的 `CURRENT_REQUEST_CONTEXT` 把上下文作用域套住整个 handler,错误转换读同一份上下文;脱离请求任务(单测、后台任务)时退回无上下文形状。取证:`check:game-distribution-owner-isolation` 新增成功/失败 envelope 两条断言,修复前 `requestId=` 为空失败、修复后 23 项 PASS。 - 决策(envelope 一致性按可消费取证):TS 侧没有 envelope 的类型镜像,只有 `packages/shared/src/http.ts` 与 `src/services/apiClient.ts` 的运行时守卫,因此这一条按「客户端能一致消费真实 envelope」取证(字段名 `ok` / `data` / `error.code` / `meta.apiVersion` / `meta.requestId`),不是类型级镜像;字段门禁比对的是名字而不是值类型。- 影响范围:`server-rs/crates/api-server/src/modules/game_distribution.rs`、`scripts/check-game-distribution-owner-isolation.mjs`、`scripts/check-game-distribution-upload-safety.mjs`、`scripts/check-game-distribution-upload-resume.mjs`、`package.json`、游戏分发里程碑取证。 -- 验证:本地真实栈三个脚本全部 PASS(越权隔离 23 项、上传安全 24 项、分片续传 prepare 8 项 + resume 9 项、媒体链路 44 项);分片续传中途杀掉 api-server 进程(PID `53844` → 重启 `7728`)后仍从 `8,388,608` 偏移续传成功;`cargo test -p api-server -- package_` 6 passed 与 `game_distribution` 27 passed、`npm run lint`、`check:encoding`、`check:doc-index`、`git diff --check`。 +- 决策(写入必须显式下发对象级 ACL):`platform-oss` 的 `OssObjectAccess` 之前只用于日志,对象继承 bucket 默认 ACL,公共读 bucket 上「private」对象可被匿名直取。现在内部 PUT、分片追加与直传 policy / 表单三处都下发 `x-oss-object-acl`(`Private` → `private`,`Public` → `public-read`);`OssAppendInternalObjectRequest` 新增 `access`,`DirectUploadTicketFormFields` 新增 `x-oss-object-acl`。验证:`cargo test -p platform-oss` 76 passed;`E2E_REQUIRE_PRIVATE_BUCKET=1` 的媒体链路 E2E 里直传封面与发行包对象匿名直取都 403(65 项 PASS)。- 验证:本地真实栈三个脚本全部 PASS(越权隔离 23 项、上传安全 24 项、分片续传 prepare 8 项 + resume 9 项、媒体链路 44 项);分片续传中途杀掉 api-server 进程(PID `53844` → 重启 `7728`)后仍从 `8,388,608` 偏移续传成功;`cargo test -p api-server -- package_` 6 passed 与 `game_distribution` 27 passed、`npm run lint`、`check:encoding`、`check:doc-index`、`git diff --check`。 - 边界:证据来自本机 dev 栈与 dev bucket;生产域名、CDN 缓存窗口与真实客户端安装版的自动上传仍未验证。 \ No newline at end of file diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 247399c5a..87709fce9 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6059,4 +6059,4 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **根因**:`server-rs/crates/platform-oss/src/lib.rs` 的 `put_internal_object_bytes` 收下 `OssObjectAccess` 却只把它交给 `oss_access_label` 写日志,PUT 请求从不带 `x-oss-object-acl`;对象 ACL 因此继承 bucket 默认值(dev 的 `agc-dev` 是允许匿名读的)。 - **判据**:`npm run check:game-distribution-media-e2e` 默认把这条打印成 `WARN`(本地 dev bucket 允许匿名读是环境现状),设 `E2E_REQUIRE_PRIVATE_BUCKET=1` 时按失败处理;本轮两种模式都实测过(默认 63 PASS + WARN,强制模式该条 FAIL)。 - **处理**:上线前必须 ① 确认生产 bucket 不是 public-read;② 给 `platform-oss` 的内部 PUT、分片追加与直传策略补对象级 ACL(或干脆在 bucket 策略层对 `agc/project-snapshots/**` 收紧)。 -- **影响面**:所有经 `platform-oss` 写入「private」对象的链路(游戏发行包、AGC 项目快照、后台导出)在公开 bucket 上都继承公开读。 \ No newline at end of file +- **已修(2026-09-28)**:`platform-oss` 现在在内部 PUT(`build_put_object_headers` 收 `access`)、分片追加(`OssAppendInternalObjectRequest` 新增 `access`)与直传 policy / 表单(`DirectUploadTicketFormFields` 新增 `x-oss-object-acl`,policy 里同值做条件约束)三处都显式下发对象级 ACL(`Private` → `private`,`Public` → `public-read`)。复验:`cargo test -p platform-oss` 76 passed;`E2E_REQUIRE_PRIVATE_BUCKET=1 npm run check:game-distribution-media-e2e` 下匿名直取直传封面与发行包对象都返回 **403**(65 项 PASS)。存量对象仍是旧的 bucket 默认 ACL,需要按需回填或换键。- **影响面**:所有经 `platform-oss` 写入「private」对象的链路(游戏发行包、AGC 项目快照、后台导出)在公开 bucket 上都继承公开读。 \ No newline at end of file diff --git a/scripts/check-game-distribution-media-e2e.mjs b/scripts/check-game-distribution-media-e2e.mjs index 55d7ef71c..619b5d905 100644 --- a/scripts/check-game-distribution-media-e2e.mjs +++ b/scripts/check-game-distribution-media-e2e.mjs @@ -39,6 +39,27 @@ function check(name, ok, detail = '') { ); } +/** + * 探测对象是否被匿名直取。 + * + * 这一条验的是「bucket / 对象 ACL」而不是接口行为:本地环境若还是公共读,默认只报 WARN, + * 设 E2E_REQUIRE_PRIVATE_BUCKET=1 时按失败处理,用于上线前的私有化复验。 + */ +async function probeAnonymousObjectAccess(url, label) { + const response = await fetch(url); + if (response.status >= 400) { + check(`匿名直取${label}被拒绝`, true, `status=${response.status}`); + return; + } + if ((process.env.E2E_REQUIRE_PRIVATE_BUCKET ?? '').trim() === '1') { + check(`匿名直取${label}被拒绝`, false, `status=${response.status}`); + return; + } + console.log( + `WARN 匿名直取${label}返回 status=${response.status}:当前 bucket/对象 ACL 不是私有(可用 E2E_REQUIRE_PRIVATE_BUCKET=1 复验)。`, + ); +} + async function api(path, options = {}) { const { method = 'GET', token, body, headers = {}, binary } = options; const finalHeaders = { ...ENVELOPE, ...headers }; @@ -592,6 +613,15 @@ async function main() { Boolean(coverRead.data?.read?.signedUrl ?? coverRead.data?.signedUrl), `status=${coverRead.status}`, ); + const signedCoverUrl = + coverRead.data?.read?.signedUrl ?? coverRead.data?.signedUrl; + if (signedCoverUrl) { + const coverHost = new URL(signedCoverUrl).host; + await probeAnonymousObjectAccess( + `https://${coverHost}/${cover.objectKey}`, + '直传上传的私有封面对象', + ); + } const shotRead = await api( `/api/assets/read-url?objectKey=${encodeURIComponent(shot1.objectKey)}`, ); @@ -1183,8 +1213,6 @@ async function main() { const directObject = await fetch( `https://${ossBucket}.${ossEndpoint}/${objectKey}`, ); - // 这一条验的是「环境里的 bucket/对象 ACL」,不是接口行为:本地 dev bucket 允许匿名读, - // 所以默认只报 WARN;上线前把 E2E_REQUIRE_PRIVATE_BUCKET=1 打开,让它在非私有环境里失败。 if (directObject.status >= 400) { check( '匿名直取私有 bucket 里的发行包对象被拒绝', diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index 93449d2bd..5a228ea94 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -298,6 +298,7 @@ fn direct_upload_ticket_form_fields_from_oss( success_action_status: value.success_action_status, content_type: value.content_type, cache_control: value.cache_control, + object_acl: value.object_acl, metadata: value.metadata, } } 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 89f07cd59..402b0ab3e 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -931,6 +931,7 @@ async fn upload_package_chunk( OssAppendInternalObjectRequest { object_key: object_key.clone(), content_type: Some("application/zip".to_string()), + access: OssObjectAccess::Private, position: offset, body: body.to_vec(), }, diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index f3c829e84..b18c13ab0 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -169,6 +169,8 @@ pub struct OssInternalPutObjectRequest { pub struct OssAppendInternalObjectRequest { pub object_key: String, pub content_type: Option, + /// 首次追加会创建对象,因此同样要显式下发对象级 ACL。 + pub access: OssObjectAccess, pub position: u64, pub body: Vec, } @@ -285,6 +287,8 @@ pub struct OssPostObjectFormFields { pub content_type: Option, #[serde(rename = "Cache-Control", skip_serializing_if = "Option::is_none")] pub cache_control: Option, + #[serde(rename = "x-oss-object-acl")] + pub object_acl: String, #[serde(flatten)] pub metadata: BTreeMap, } @@ -594,6 +598,7 @@ impl OssClient { &metadata, &credential, &signature_date, + request.access, ); let policy = serde_json::to_string(&policy_json).map_err(|error| { OssError::SerializePolicy(format!("序列化 policy 失败:{error}")) @@ -629,6 +634,7 @@ impl OssClient { success_action_status: success_action_status.to_string(), content_type, cache_control, + object_acl: oss_object_acl_value(request.access).to_string(), metadata, }, }) @@ -1102,7 +1108,8 @@ impl OssClient { .append_pair("position", &request.position.to_string()); let appended_bytes = u64::try_from(request.body.len()) .map_err(|_| OssError::InvalidRequest("追加内容大小超出可支持范围".to_string()))?; - let headers = BTreeMap::new(); + let mut headers = BTreeMap::new(); + apply_object_acl_header(&mut headers, request.access); let builder = signed_request_builder( client, &self.config, @@ -1184,7 +1191,7 @@ impl OssClient { )); } let content_type = normalize_optional_value(content_type); - let headers = build_put_object_headers(metadata)?; + let headers = build_put_object_headers(metadata, access)?; let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key) .map_err(|error| { request_error( @@ -1328,7 +1335,7 @@ impl OssClient { let file_name = sanitize_file_name(&request.file_name)?; let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name); let content_type = normalize_optional_value(request.content_type); - let headers = build_put_object_headers(request.metadata)?; + let headers = build_put_object_headers(request.metadata, request.access)?; let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key).map_err( |error| { @@ -1563,7 +1570,7 @@ impl OssClient { let file_name = sanitize_file_name(&request.file_name)?; let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name); let content_type = normalize_optional_value(request.content_type); - let headers = build_put_object_headers(request.metadata)?; + let headers = build_put_object_headers(request.metadata, request.access)?; let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key) .map_err(|error| { request_error( @@ -2073,6 +2080,7 @@ fn build_policy_json( metadata: &BTreeMap, credential: &str, signature_date: &str, + access: OssObjectAccess, ) -> Value { let mut conditions = vec![ json!({ "bucket": bucket }), @@ -2096,6 +2104,13 @@ fn build_policy_json( conditions.push(json!(["eq", "$Cache-Control", cache_control])); } + // 直传同样必须显式下发对象级 ACL;policy 里不带这条时 OSS 会忽略表单字段。 + conditions.push(json!([ + "eq", + "$x-oss-object-acl", + oss_object_acl_value(access) + ])); + for (key, value) in metadata { conditions.push(json!(["eq", format!("${key}"), value])); } @@ -2384,6 +2399,7 @@ fn normalize_metadata( fn build_put_object_headers( metadata: BTreeMap, + access: OssObjectAccess, ) -> Result, OssError> { // 中文注释:生成资产 object key 含会话与 asset id,内容不可变,适合交给浏览器/CDN 长缓存。 let mut headers = BTreeMap::from([( @@ -2391,9 +2407,28 @@ fn build_put_object_headers( DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string(), )]); headers.extend(normalize_metadata(metadata)?); + apply_object_acl_header(&mut headers, access); Ok(headers) } +/// OSS 对象级 ACL 取值。 +/// +/// 写入时必须显式下发:不下发时对象继承 bucket 默认 ACL,bucket 若是公共读, +/// 声明为 `Private` 的发行包 / 项目快照也会被匿名直取(见 pitfalls 2026-09-28 条目)。 +fn oss_object_acl_value(access: OssObjectAccess) -> &'static str { + match access { + OssObjectAccess::Public => "public-read", + OssObjectAccess::Private => "private", + } +} + +fn apply_object_acl_header(headers: &mut BTreeMap, access: OssObjectAccess) { + headers.insert( + "x-oss-object-acl".to_string(), + oss_object_acl_value(access).to_string(), + ); +} + fn normalize_metadata_key(raw: &str) -> String { let stripped = raw .trim() @@ -3691,6 +3726,11 @@ mod tests { policy["conditions"][8], json!(["eq", "$Cache-Control", DEFAULT_IMMUTABLE_CACHE_CONTROL]) ); + assert_eq!( + policy["conditions"][9], + json!(["eq", "$x-oss-object-acl", "private"]) + ); + assert_eq!(response.form_fields.object_acl, "private".to_string()); assert_eq!( response.form_fields.cache_control, Some(DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string()) @@ -4096,10 +4136,10 @@ mod tests { #[test] fn put_object_headers_include_immutable_cache_control_for_generated_assets() { - let headers = build_put_object_headers(BTreeMap::from([( - "asset-kind".to_string(), - "puzzle-cover".to_string(), - )])) + let headers = build_put_object_headers( + BTreeMap::from([("asset-kind".to_string(), "puzzle-cover".to_string())]), + OssObjectAccess::Private, + ) .expect("headers should build"); assert_eq!( @@ -4112,6 +4152,23 @@ mod tests { ); } + #[test] + fn put_object_headers_carry_explicit_object_acl_for_both_accesses() { + let private = build_put_object_headers(BTreeMap::new(), OssObjectAccess::Private) + .expect("private headers should build"); + let public = build_put_object_headers(BTreeMap::new(), OssObjectAccess::Public) + .expect("public headers should build"); + + assert_eq!( + private.get("x-oss-object-acl"), + Some(&"private".to_string()) + ); + assert_eq!( + public.get("x-oss-object-acl"), + Some(&"public-read".to_string()) + ); + } + #[tokio::test] async fn put_object_rejects_empty_body_before_calling_oss() { let client = build_client(); diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index f06c23d9c..d72468642 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -1026,6 +1026,9 @@ pub struct DirectUploadTicketFormFields { pub content_type: Option, #[serde(rename = "Cache-Control", skip_serializing_if = "Option::is_none")] pub cache_control: Option, + /// 直传表单必须带上对象级 ACL,policy 里已按同一取值做条件约束。 + #[serde(rename = "x-oss-object-acl")] + pub object_acl: String, #[serde(flatten)] pub metadata: BTreeMap, } @@ -1170,6 +1173,7 @@ mod tests { success_action_status: "200".to_string(), content_type: Some("image/png".to_string()), cache_control: Some("public, max-age=31536000, immutable".to_string()), + object_acl: "private".to_string(), metadata: BTreeMap::from([( "x-oss-meta-asset-kind".to_string(), "character_visual".to_string(),