修复发行游戏在沙箱中访问存储报错
将运行期存储兼容层保存为独立 JavaScript 文件,通过 include_str 注入发行 HTML。 保持 opaque sandbox 隔离,仅提供当前运行实例的 localStorage 与 sessionStorage 兼容能力。 同步发行沙箱验收脚本和玩法运行契约文档。
This commit is contained in:
@@ -89,7 +89,7 @@
|
||||
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install`(复用 `project.bootstrap`)与 `npm run build`(复用 `project.verify` 的受控 npm 运行器,脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义),再把 `game/dist` 归一化成根 `index.html` 的发行包上传;已有可玩入口(`game/index.html` 或 `dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此口径端到端验证(构建产物、发行网关与网页沙箱播放)。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。灰度默认关闭:未配置该键、或 `enabled=false` 时,未登录与已登录作者都拿到不开放(发布入口不渲染、写入口 503);运营在后台创建该键并 `enabled=true` 后,只有白名单 / 灰度比例 / 用户标签命中的作者拿到开放状态。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 聊天头「发布到游戏广场」按钮据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。AGC 发布面板不展示 ZIP 路径、文件数或体积等技术摘要;一句话简介与分类可根据有界、脱敏的创作上下文免费生成(不扣用户泥点,仍可编辑),分类必须收敛到上述白名单;游戏封面支持基于项目上下文生成,生成走现役图片生成与泥点扣费链路,产物必须登记为当前账号平台素材后才能作为 `coverAssetId` 提交。
|
||||
6. `supportedDevices` 至少包含 `desktop` 或 `mobile`;`inputModes` 来自 `keyboard`、`mouse`、`touch`;声明移动端必须包含 `touch`。`orientation` 为 `landscape`、`portrait` 或 `responsive`。这些是待人工复核的作者声明,目录只显示已经随版本审核通过的值。
|
||||
7. 原始 ZIP、未审核展开目录、审核资料均为私有对象;公开版本不暴露源码镜像键、本地路径、访问凭据或私有账号元数据。运行文件只能由发行网关按游戏、版本和文件白名单读取,不能绕过网关访问公开 OSS bucket。
|
||||
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}`(含尾斜杠)等价于该游戏的 `index.html`,`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff`、`Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`;边缘在转发到发行网关前清空 `Cookie`,游戏文档又运行在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie 与 storage。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。
|
||||
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}`(含尾斜杠)等价于该游戏的 `index.html`,`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff`、`Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP,并在游戏脚本前注入隔离的运行期 `localStorage` / `sessionStorage` 兼容层,避免游戏直接读取 opaque origin 原生 storage 时抛 `SecurityError`。兼容层只在当前运行实例内存中有效,不读取平台 Cookie、主站 DOM 或账号数据。带平台 `Cookie` 的请求一律 `403`;边缘在转发到发行网关前清空 `Cookie`。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。
|
||||
9. 发行入口既不由管理员填写,也不需要部署侧配置:审核通过时 `api-server` 按 gameId 派生**平台同源路径** `/games/{gameId}/` 写入公开投影,dev / release / 预览环境口径完全一致,不再需要发行域名、通配 DNS 或通配证书。gameId 必须是服务端生成的稳定标识(只允许 `[A-Za-z0-9_-]`),派生失败时审核通过直接失败,不回落主站其它路径、内网地址或任意外部地址。客户端读取该字段时按当前 origin 解析成绝对地址再交给 iframe;历史数据里的绝对 URL(非当前源的 https)继续兼容,新写入只用相对路径。路径到发行网关的映射由边缘 nginx 的同源发行入口 location 完成。
|
||||
|
||||
### 身份、状态、审核与更新
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
// E2E_CHROMIUM_EXECUTABLE=<ms-playwright 里的 chrome.exe,可省略>
|
||||
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-sandbox-e2e
|
||||
//
|
||||
// 覆盖:ES module 与同包资源能在 opaque sandbox 下载入;外站 fetch / WebSocket 被挡;
|
||||
// localStorage / document.cookie / 父文档 DOM 都拿不到;顶层跳转与弹窗被挡;
|
||||
// 覆盖:ES module 与同包资源能在 opaque sandbox 下载入;运行期 storage 兼容层可用;
|
||||
// 外站 fetch / WebSocket 被挡;document.cookie / 父文档 DOM 都拿不到;顶层跳转和弹窗被挡;
|
||||
// 敏感权限(定位)被拒;同时核对发行网关的 CSP / nosniff / CORS / Cookie 403 策略。
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
@@ -140,14 +140,14 @@ const PROBE_APP = `import { marker } from './helper.js';
|
||||
|
||||
const results = { moduleLoaded: marker === 'helper-ok' };
|
||||
|
||||
// 探针自身也可能被浏览器直接抛错挡下(例如 opaque origin 读 cookie),
|
||||
// 所以每一步单独兜底,最后无论如何都把结果 postMessage 给父页面。
|
||||
try {
|
||||
// opaque origin 的原生 storage 不可用,但发行网关会在游戏脚本前注入运行期兼容层。
|
||||
try {
|
||||
window.localStorage.getItem('probe');
|
||||
results.storageBlocked = false;
|
||||
window.localStorage.setItem('probe', 'ok');
|
||||
results.storageAvailable = window.localStorage.getItem('probe') === 'ok';
|
||||
window.localStorage.removeItem('probe');
|
||||
} catch {
|
||||
results.storageBlocked = true;
|
||||
results.storageAvailable = false;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -479,8 +479,8 @@ document.body.appendChild(frame);
|
||||
results.moduleLoaded === true,
|
||||
);
|
||||
check(
|
||||
'localStorage 在 opaque sandbox 下不可用',
|
||||
results.storageBlocked === true,
|
||||
'opaque sandbox 下运行期 storage 兼容层可用',
|
||||
results.storageAvailable === true,
|
||||
);
|
||||
check(
|
||||
'document.cookie 在 opaque sandbox 下为空',
|
||||
|
||||
@@ -99,6 +99,14 @@ const RELEASE_PACKAGE_CACHE_MAX_ENTRIES: usize = 4;
|
||||
/// 缓存字节预算必须比单个发行包上限大出一档,否则 200 MiB 档的包只能刚好自占整份预算,
|
||||
/// 任何并发的小包都会被立刻挤掉。
|
||||
const RELEASE_PACKAGE_CACHE_MAX_BYTES: usize = 256 * 1024 * 1024;
|
||||
/// 发行包运行在 `sandbox="allow-scripts"` 的 opaque origin 中,浏览器原生 storage
|
||||
/// 会抛 `SecurityError`。不授予 `allow-same-origin`(否则同源脚本可能移除 sandbox),
|
||||
/// 而是在游戏脚本前安装本次运行期的同步兼容存储;它不接触平台 Cookie、DOM 或账号数据。
|
||||
const RELEASE_STORAGE_BOOTSTRAP: &str = concat!(
|
||||
"<script>",
|
||||
include_str!("game_distribution_release_storage_bootstrap.js"),
|
||||
"</script>",
|
||||
);
|
||||
|
||||
/// 发行静态资源的进程内缓存。
|
||||
///
|
||||
@@ -840,9 +848,20 @@ async fn serve_release_asset(
|
||||
}
|
||||
Err(_) => return Err(AppError::from_status(StatusCode::NOT_FOUND)),
|
||||
};
|
||||
let content = inject_release_storage_bootstrap(content, content_type);
|
||||
Ok(release_asset_response(content, content_type))
|
||||
}
|
||||
|
||||
fn inject_release_storage_bootstrap(content: Vec<u8>, content_type: &str) -> Vec<u8> {
|
||||
if !content_type.starts_with("text/html") {
|
||||
return content;
|
||||
}
|
||||
let mut result = Vec::with_capacity(RELEASE_STORAGE_BOOTSTRAP.len() + content.len());
|
||||
result.extend_from_slice(RELEASE_STORAGE_BOOTSTRAP.as_bytes());
|
||||
result.extend_from_slice(&content);
|
||||
result
|
||||
}
|
||||
|
||||
/// 读取(并按对象键缓存)已确认的私有发行包。
|
||||
async fn release_package_bytes(
|
||||
state: &AppState,
|
||||
@@ -4401,6 +4420,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_html_injects_opaque_storage_compatibility_before_game_code() {
|
||||
let html = inject_release_storage_bootstrap(
|
||||
b"<!doctype html><script>window.started = true;</script>".to_vec(),
|
||||
"text/html; charset=utf-8",
|
||||
);
|
||||
let html = String::from_utf8(html).expect("injected html");
|
||||
assert!(html.starts_with(RELEASE_STORAGE_BOOTSTRAP));
|
||||
assert!(html.contains("window.started = true"));
|
||||
assert_eq!(
|
||||
inject_release_storage_bootstrap(vec![1, 2, 3], "image/png"),
|
||||
vec![1, 2, 3]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_response_sets_nosniff_and_scopes_csp_to_html() {
|
||||
let html = release_asset_response(b"<html></html>".to_vec(), "text/html; charset=utf-8");
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
(function () {
|
||||
function makeStorage() {
|
||||
var values = Object.create(null);
|
||||
return {
|
||||
get length() {
|
||||
return Object.keys(values).length;
|
||||
},
|
||||
key: function (index) {
|
||||
return Object.keys(values)[index] || null;
|
||||
},
|
||||
getItem: function (key) {
|
||||
key = String(key);
|
||||
return Object.prototype.hasOwnProperty.call(values, key)
|
||||
? values[key]
|
||||
: null;
|
||||
},
|
||||
setItem: function (key, value) {
|
||||
values[String(key)] = String(value);
|
||||
},
|
||||
removeItem: function (key) {
|
||||
delete values[String(key)];
|
||||
},
|
||||
clear: function () {
|
||||
values = Object.create(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function install(name) {
|
||||
try {
|
||||
Object.defineProperty(window, name, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: makeStorage(),
|
||||
});
|
||||
} catch (_) {
|
||||
// opaque-origin native storage may reject property replacement; keep the sandbox intact.
|
||||
}
|
||||
}
|
||||
|
||||
install('localStorage');
|
||||
install('sessionStorage');
|
||||
})();
|
||||
Reference in New Issue
Block a user