// 游戏发行沙箱的真实浏览器检查:把「探针包」发布到本地发行网关,再用 Chromium 以 // `sandbox="allow-scripts"` 的 iframe 打开它,断言不透明来源下能载入什么、被挡掉了什么。 // // 需要:本地 dev 栈(SpacetimeDB + api-server)+ 管理员账号 + playwright。 // npm install --prefix %TEMP%\genarrative-pw --no-save --no-package-lock playwright // E2E_PLAYWRIGHT_DIR=%TEMP%\genarrative-pw // E2E_CHROMIUM_EXECUTABLE= // E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-sandbox-e2e // // 覆盖: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'; import JSZip from 'jszip'; 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:本脚本要按发布灰度口径打开写入口并审核探针包。', ); 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(pathname, 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}${pathname}`, { 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 = `sandbox-${id}.png`; const ticket = await api('/api/assets/direct-upload-tickets', { method: 'POST', token, body: { legacyPrefix: 'generated-character-drafts', pathSegments: ['game-distribution', 'sandbox', 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-sandbox', }, }); if (confirm.status !== 200) { throw new Error( `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, ); } return confirm.data.assetObject.assetObjectId; } const PROBE_HELPER = "export const marker = 'helper-ok';\n"; const PROBE_APP = `import { marker } from './helper.js'; const results = { moduleLoaded: marker === 'helper-ok' }; try { // opaque origin 的原生 storage 不可用,但发行网关会在游戏脚本前注入运行期兼容层。 try { window.localStorage.setItem('probe', 'ok'); results.storageAvailable = window.localStorage.getItem('probe') === 'ok'; window.localStorage.removeItem('probe'); } catch { results.storageAvailable = false; } try { results.cookieHidden = document.cookie === ''; } catch { results.cookieHidden = true; } try { void window.parent.document.body; results.parentDomBlocked = false; } catch { results.parentDomBlocked = true; } results.crossOriginFetchBlocked = await fetch('http://127.0.0.1:4199/readyz') .then(() => false) .catch(() => true); results.crossOriginWebSocketBlocked = await new Promise((resolve) => { let settled = false; const finish = (value) => { if (settled) return; settled = true; resolve(value); }; try { const socket = new WebSocket('ws://127.0.0.1:4199/probe'); const timer = setTimeout(() => { try { socket.close(); } catch {} finish(false); }, 1500); socket.onopen = () => { clearTimeout(timer); socket.close(); finish(false); }; socket.onerror = () => { clearTimeout(timer); finish(true); }; } catch { finish(true); } }); // CSP 挡 Worker 时不一定同步抛错,会以 worker 的 error 事件报出来。 results.workerBlocked = await new Promise((resolve) => { let settled = false; const finish = (value) => { if (settled) return; settled = true; resolve(value); }; try { const worker = new Worker( URL.createObjectURL( new Blob(['self.postMessage(1)'], { type: 'text/javascript' }), ), ); const timer = setTimeout(() => { worker.terminate(); finish(false); }, 1500); worker.onerror = () => { clearTimeout(timer); worker.terminate(); finish(true); }; worker.onmessage = () => { clearTimeout(timer); worker.terminate(); finish(false); }; } catch { finish(true); } }); try { results.popupBlocked = window.open('https://example.com') === null; } catch { results.popupBlocked = true; } try { window.top.location.href = 'about:blank#probe-top'; results.topNavigationBlocked = false; } catch { results.topNavigationBlocked = true; } let permissionState = 'unknown'; try { const status = await navigator.permissions.query({ name: 'geolocation' }); permissionState = status.state; } catch { permissionState = 'blocked'; } results.sensitivePermissionDenied = permissionState === 'denied' || permissionState === 'blocked'; } catch (error) { results.probeError = String(error); } parent.postMessage({ type: 'probe-results', results }, '*'); `; async function buildProbePackage() { const zip = new JSZip(); zip.file( 'index.html', 'sandbox probe' + '

sandbox probe

', ); zip.file('assets/app.js', PROBE_APP); zip.file('assets/helper.js', PROBE_HELPER); const bytes = await zip.generateAsync({ type: 'uint8array' }); return Buffer.from(bytes); } async function loadPlaywright() { const dir = (process.env.E2E_PLAYWRIGHT_DIR ?? '').trim(); if (!dir) return import('playwright'); const requireFromDir = createRequire(path.join(dir, 'noop.js')); return requireFromDir('playwright'); } 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 stamp = Date.now(); const register = await api('/api/auth/entry', { method: 'POST', body: { purePhoneNumber: `134${String(stamp).slice(-8)}`, password: DEV_PASSWORD, }, }); const author = register.data?.token; check( '作者注册拿到 token', register.status === 200 && Boolean(author), `status=${register.status}`, ); if (!author) 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 packageBytes = await buildProbePackage(); const packageSha256 = (await import('node:crypto')) .createHash('sha256') .update(packageBytes) .digest('hex'); const coverAssetId = await uploadCover(author, stamp); const title = `发行沙箱 ${String(stamp).slice(-6)}`; const metadata = { ...gameMetadata(title), coverAssetId }; const created = await api('/api/game-distribution/games', { method: 'POST', token: author, headers: { 'Idempotency-Key': `sandbox-game-${stamp}` }, body: metadata, }); 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, headers: { 'Idempotency-Key': `sandbox-version-${stamp}` }, body: { packageSha256, packageBytes: packageBytes.length, packageFileCount: 3, packageEntryPath: 'index.html', gameMetadata: metadata, }, }, ); const versionId = versionResponse.data?.versionId; const uploadPackage = await api( `/api/game-distribution/versions/${versionId}/package`, { method: 'PUT', token: author, headers: { 'Idempotency-Key': `sandbox-upload-${stamp}`, 'Content-Type': 'application/zip', }, binary: packageBytes, }, ); check( '探针包上传成功', uploadPackage.status === 200 && uploadPackage.data?.status === 'uploaded', `status=${uploadPackage.status}`, ); const submitted = await api( `/api/game-distribution/versions/${versionId}/submit`, { method: 'POST', token: author, headers: { 'Idempotency-Key': `sandbox-submit-${stamp}` }, body: { expectedPublicationRevision: 0 }, }, ); const approved = await api( `/admin/api/game-distribution/versions/${versionId}/review`, { method: 'POST', token: admin, headers: { 'Idempotency-Key': `sandbox-approve-${stamp}` }, body: { decision: 'approve', expectedPublicationRevision: 0 }, }, ); check( '探针包送审并通过审核', submitted.status === 202 && approved.status === 200, `submit=${submitted.status} approve=${approved.status}`, ); const entryUrl = `${API}/api/game-distribution/releases/${gameId}/index.html`; const entryResponse = await fetch(entryUrl); const csp = entryResponse.headers.get('content-security-policy') ?? ''; check( '发行文档带最小权限 CSP(connect-src self / worker-src none)', entryResponse.status === 200 && csp.includes("connect-src 'self'") && csp.includes("worker-src 'none'"), `status=${entryResponse.status} csp=${csp.slice(0, 60)}…`, ); check( '发行响应带 nosniff 与跨源 CORS', entryResponse.headers.get('x-content-type-options') === 'nosniff' && entryResponse.headers.get('access-control-allow-origin') === '*', `nosniff=${entryResponse.headers.get('x-content-type-options')} acao=${entryResponse.headers.get('access-control-allow-origin')}`, ); const cookieRequest = await fetch(entryUrl, { headers: { Cookie: 'genarrative_access_token=probe' }, }); check( '带平台 Cookie 的发行请求 403', cookieRequest.status === 403, `status=${cookieRequest.status}`, ); const unknownExtension = await fetch( `${API}/api/game-distribution/releases/${gameId}/secrets.env`, ); check( '未知扩展名不通过发行网关下发', unknownExtension.status === 404, `status=${unknownExtension.status}`, ); const { chromium } = await loadPlaywright(); const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim(); const browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}), }); try { const page = await browser.newPage(); page.on('console', (message) => { console.log(` [page:${message.type()}] ${message.text()}`); }); page.on('pageerror', (error) => { console.log(` [pageerror] ${error}`); }); const harness = `harness `; await page.setContent(harness); await page.waitForFunction(() => window.__probeResults !== null, null, { timeout: 30_000, }); const results = await page.evaluate(() => window.__probeResults); console.log('探针结果:', JSON.stringify(results)); check( 'opaque sandbox 下 ES module 与同包资源能载入', results.moduleLoaded === true, ); check( 'opaque sandbox 下运行期 storage 兼容层可用', results.storageAvailable === true, ); check( 'document.cookie 在 opaque sandbox 下为空', results.cookieHidden === true, ); check('读不到父文档 DOM', results.parentDomBlocked === true); check('跨源 fetch 被挡', results.crossOriginFetchBlocked === true); check('跨源 WebSocket 被挡', results.crossOriginWebSocketBlocked === true); check('Worker 被 CSP 挡下', results.workerBlocked === true); check('弹窗被 sandbox 挡下', results.popupBlocked === true); check('顶层跳转被挡', results.topNavigationBlocked === true); check('敏感权限(定位)被拒', results.sensitivePermissionDenied === true); const harnessUrl = page.url(); check( '探针没有改变父页面地址', harnessUrl === 'about:blank', `url=${harnessUrl}`, ); } finally { await browser.close(); } 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-sandbox-e2e] 运行失败:${error}`); process.exit(1); });