写入对象存储时下发对象级 ACL,修掉发行包可匿名直取
Project CI / AI game creator shell Rust crates (push) Successful in 1m30s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m52s
Project CI / Backend tests (push) Successful in 4m55s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m4s
Project CI / Native shell tests (push) Successful in 6m20s
Project CI / Frontend tests (push) Successful in 2m11s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m6s
Project CI / AI game creator shell web tests (push) Successful in 1m40s
Project CI / Repository checks (push) Successful in 2m12s

- platform-oss 的内部 PUT 与分片追加显式下发 x-oss-object-acl(Private 为 private、Public 为 public-read),OssAppendInternalObjectRequest 增加 access 字段
- 直传 policy 增加 x-oss-object-acl 条件,表单字段与 shared-contracts 的 DirectUploadTicketFormFields 同步新增并透传到 ticket 响应
- 媒体链路 E2E 增加匿名直取直传封面与发行包两条探测:默认 WARN,E2E_REQUIRE_PRIVATE_BUCKET=1 时按失败处理
- 里程碑第 7 条、pitfalls 与 decision-log 记录根因、修复与复验(严格模式 65 项 PASS,两处匿名直取都 403)
This commit is contained in:
kdletters
2026-09-28 19:20:21 +08:00
parent fb87e1d4ff
commit 25a992468d
8 changed files with 104 additions and 13 deletions
@@ -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/<gameId>/<versionId>.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,本轮未做。
@@ -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 缓存窗口与真实客户端安装版的自动上传仍未验证。
@@ -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 上都继承公开读。
- **已修(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 上都继承公开读。
+30 -2
View File
@@ -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 里的发行包对象被拒绝',
@@ -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,
}
}
@@ -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(),
},
+65 -8
View File
@@ -169,6 +169,8 @@ pub struct OssInternalPutObjectRequest {
pub struct OssAppendInternalObjectRequest {
pub object_key: String,
pub content_type: Option<String>,
/// 首次追加会创建对象,因此同样要显式下发对象级 ACL。
pub access: OssObjectAccess,
pub position: u64,
pub body: Vec<u8>,
}
@@ -285,6 +287,8 @@ pub struct OssPostObjectFormFields {
pub content_type: Option<String>,
#[serde(rename = "Cache-Control", skip_serializing_if = "Option::is_none")]
pub cache_control: Option<String>,
#[serde(rename = "x-oss-object-acl")]
pub object_acl: String,
#[serde(flatten)]
pub metadata: BTreeMap<String, String>,
}
@@ -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<String, String>,
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<String, String>,
access: OssObjectAccess,
) -> Result<BTreeMap<String, String>, 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<String, String>, 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();
@@ -1026,6 +1026,9 @@ pub struct DirectUploadTicketFormFields {
pub content_type: Option<String>,
#[serde(rename = "Cache-Control", skip_serializing_if = "Option::is_none")]
pub cache_control: Option<String>,
/// 直传表单必须带上对象级 ACL,policy 里已按同一取值做条件约束。
#[serde(rename = "x-oss-object-acl")]
pub object_acl: String,
#[serde(flatten)]
pub metadata: BTreeMap<String, String>,
}
@@ -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(),