修复付费游戏播放会话在边缘转发 Cookie 导致 403 不可玩
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
- nginx:三份模板在通用 /api location 之前新增 location ^~ /api/game-distribution/play-sessions/,代理头与通用 /api 一致并清空 Cookie - nginx:^~ 保证该前缀不被正则 location ~ ^/api(?:/|$) 抢先;只匹配带尾斜杠的前缀,创建会话端点继续走通用 /api 并保留 Cookie - pingora:新增 RouteDecision::PlaySessionGateway,走 api 上游并同样套用 api 限流分组、大小上限与维护闸 - pingora:抽出 route_clears_cookie,发行入口 ReleaseGateway 与播放会话入口在上游代理阶段统一清空 Cookie - pingora:classify_path 在通用 /api 分支之前命中播放会话前缀,并新增播放会话路由、Cookie 清除与保护等级用例 - 路由矩阵:新增 play_sessions_gateway 用例,声明清空 Cookie 与 protectionClass api - 门禁 check:nginx-spa-routes:新增播放会话前缀断言——三份模板存在 ^~ location、块内清空 Cookie、代理头齐全且排在通用 /api location 之前 - 门禁 check:pingora-route-parity:新增断言——平台内容网关用例必须清空 Cookie 且不得复用通用 /api location,Rust 播放会话分支必须排在通用 /api 之前并由 route_clears_cookie 清理 - 门禁 check:pingora-gateway-smoke:新增真实网关用例——播放会话前缀转发到 api 上游并清空 Cookie、创建会话端点保留 Cookie - dev:vite.config.ts 在 /api/game-distribution 规则之前新增同名前缀代理并清除 Cookie - 文档:同步 Pingora 试点文档、本地开发运维文档、deploy/nginx/README 与 shared-memory 决策/踩坑记录
This commit is contained in:
@@ -346,6 +346,74 @@ function validateMaintenanceInternalBypass() {
|
||||
}
|
||||
}
|
||||
|
||||
const PLAY_SESSION_LOCATION = 'location ^~ /api/game-distribution/play-sessions/';
|
||||
const GENERIC_API_LOCATION = 'location ~ ^/api(?:/|$)';
|
||||
|
||||
/** 取某个 location 头之后配对的花括号块内容;找不到返回 null。 */
|
||||
function findLocationBody(source, locationHeader) {
|
||||
const start = source.indexOf(locationHeader);
|
||||
if (start < 0) {
|
||||
return null;
|
||||
}
|
||||
const openBrace = source.indexOf('{', start);
|
||||
if (openBrace < 0) {
|
||||
return null;
|
||||
}
|
||||
let depth = 0;
|
||||
for (let index = openBrace; index < source.length; index += 1) {
|
||||
if (source[index] === '{') {
|
||||
depth += 1;
|
||||
} else if (source[index] === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return source.slice(openBrace + 1, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 付费游戏播放会话前缀(sandbox iframe src 的前缀,包内相对资源沿同一前缀解析)必须有自己的
|
||||
* `^~` 前缀 location 并清空 Cookie:api-server 播放网关对带平台 refresh Cookie 的请求返回 403,
|
||||
* Cookie 一旦被边缘转发,iframe 与包内每个资源都会 403,付费游戏实际不可玩。
|
||||
* `^~` 不能省——不加时正则 location `~ ^/api(?:/|$)` 优先级更高,Cookie 又会被转发回去;
|
||||
* 前缀末尾的斜杠也不能省——创建会话的 `POST /api/game-distribution/play-sessions` 需要账号凭证。
|
||||
*/
|
||||
function validatePlaySessionCookieIsolation() {
|
||||
for (const nginxPath of NGINX_PATHS) {
|
||||
const source = readFileSync(nginxPath, 'utf8');
|
||||
const playSessionIndex = source.indexOf(PLAY_SESSION_LOCATION);
|
||||
if (playSessionIndex < 0) {
|
||||
fail(`${nginxPath} 缺少播放会话前缀 location:${PLAY_SESSION_LOCATION}`);
|
||||
continue;
|
||||
}
|
||||
const body = findLocationBody(source, PLAY_SESSION_LOCATION);
|
||||
if (body === null) {
|
||||
fail(`${nginxPath} 播放会话前缀 location 没有配对的闭合块。`);
|
||||
continue;
|
||||
}
|
||||
for (const fragment of [
|
||||
'proxy_set_header Cookie "";',
|
||||
'proxy_pass http://genarrative_api;',
|
||||
'proxy_set_header Host $host;',
|
||||
'proxy_set_header X-Real-IP $remote_addr;',
|
||||
'proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;',
|
||||
'proxy_set_header X-Forwarded-Proto $scheme;',
|
||||
]) {
|
||||
if (!body.includes(fragment)) {
|
||||
fail(`${nginxPath} 播放会话前缀 location 缺少代理片段:${fragment}`);
|
||||
}
|
||||
}
|
||||
const genericApiIndex = source.indexOf(GENERIC_API_LOCATION);
|
||||
if (genericApiIndex >= 0 && playSessionIndex > genericApiIndex) {
|
||||
fail(
|
||||
`${nginxPath} 播放会话前缀 location 必须排在通用 /api location(${GENERIC_API_LOCATION})之前。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const expectedMainSpaRoutes = collectExpectedMainSpaRoutes();
|
||||
export const expectedPrefixRoutes = collectExpectedPrefixRoutes();
|
||||
|
||||
@@ -358,6 +426,7 @@ if (isMainModule) {
|
||||
validateNginxRoutes(nginxPath, expectedMainSpaRoutes, expectedPrefixRoutes);
|
||||
}
|
||||
validateMaintenanceInternalBypass();
|
||||
validatePlaySessionCookieIsolation();
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('[check:nginx-spa-routes] FAILED');
|
||||
|
||||
@@ -1060,6 +1060,74 @@ async function runSmokeCases(
|
||||
'发行入口形状不符时仍是真实 404',
|
||||
);
|
||||
|
||||
// 平台付费游戏播放会话入口:/api/game-distribution/play-sessions/<token>/… 原样转发到 api 上游,
|
||||
// 但必须清空 Cookie——api-server 播放网关对带平台 refresh Cookie 的请求返回 403(纵深防御保留),
|
||||
// Cookie 一旦被转发,iframe 与包内每个相对资源都会 403。
|
||||
const playSessionPath =
|
||||
'/api/game-distribution/play-sessions/token_smoke/index.html';
|
||||
const playSessionBeforeCount = api.state.requests.length;
|
||||
const playSessionResponse = await expectHttp(
|
||||
baseUrl,
|
||||
playSessionPath,
|
||||
200,
|
||||
'"upstream":"api"',
|
||||
'播放会话入口转发到 api 上游且清空 Cookie',
|
||||
{
|
||||
headers: {
|
||||
// API 接流保护是 1 req/s、无 burst,这里用独立来源 IP 取一个干净的令牌桶,
|
||||
// 避免与同一进程里其它 loopback API 用例互相耗尽配额(与下面 429 用例同法)。
|
||||
'X-Forwarded-For': '203.0.113.71',
|
||||
'X-Request-Id': 'smoke-play-session-request-id',
|
||||
Host: 'example.test',
|
||||
Cookie: 'session=smoke-must-not-reach-play-session',
|
||||
},
|
||||
},
|
||||
);
|
||||
const playSessionPayload = JSON.parse(playSessionResponse.body);
|
||||
ensure(
|
||||
playSessionPayload.url === playSessionPath,
|
||||
`播放会话入口上游路径不应被重写:${playSessionPayload.url}`,
|
||||
);
|
||||
const playSessionUpstreamRequests = api.state.requests.slice(
|
||||
playSessionBeforeCount,
|
||||
);
|
||||
ensure(
|
||||
playSessionUpstreamRequests.length === 1 &&
|
||||
playSessionUpstreamRequests[0].url === playSessionPath,
|
||||
`播放会话入口上游请求不符:${describeRequests(playSessionUpstreamRequests)}`,
|
||||
);
|
||||
ensure(
|
||||
playSessionUpstreamRequests[0]?.headers.cookie === undefined,
|
||||
`播放会话入口没有清空 Cookie:${describeRequests(playSessionUpstreamRequests)}`,
|
||||
);
|
||||
|
||||
// 创建会话的 `POST /api/game-distribution/play-sessions`(无尾斜杠)需要账号凭证,
|
||||
// 必须继续走通用 `/api` 规则并保留 Cookie。
|
||||
const createSessionBeforeCount = api.state.requests.length;
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/api/game-distribution/play-sessions',
|
||||
200,
|
||||
'"upstream":"api"',
|
||||
'创建会话入口保留 Cookie',
|
||||
{
|
||||
headers: {
|
||||
'X-Forwarded-For': '203.0.113.72',
|
||||
Host: 'example.test',
|
||||
Cookie: 'session=smoke-must-reach-create-session',
|
||||
},
|
||||
},
|
||||
);
|
||||
const createSessionUpstreamRequests = api.state.requests.slice(
|
||||
createSessionBeforeCount,
|
||||
);
|
||||
ensure(
|
||||
createSessionUpstreamRequests.length === 1 &&
|
||||
createSessionUpstreamRequests[0]?.headers.cookie ===
|
||||
'session=smoke-must-reach-create-session',
|
||||
`创建会话入口不应清空 Cookie:${describeRequests(createSessionUpstreamRequests)}`,
|
||||
);
|
||||
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/api/upload',
|
||||
|
||||
@@ -18,6 +18,7 @@ const VALID_KINDS = new Set([
|
||||
'proxy',
|
||||
'static',
|
||||
'release_gateway',
|
||||
'play_session_gateway',
|
||||
'redirect_permanent',
|
||||
'shadow_probe',
|
||||
'not_found',
|
||||
@@ -45,6 +46,7 @@ const REQUIRED_ROUTE_IDS = [
|
||||
'profile_spa_fallback',
|
||||
'games_spa_fallback',
|
||||
'games_release_gateway',
|
||||
'play_sessions_gateway',
|
||||
'web_root_spa',
|
||||
'web_spa_case_trailing_slash',
|
||||
'web_unknown_path_exact',
|
||||
@@ -111,6 +113,20 @@ function validateExpectation(route) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (expect.kind === 'play_session_gateway') {
|
||||
// 播放会话入口与通用 `/api` 同口径(同样吃 api 限流),但路径不重写、也不引入新的头部语义;
|
||||
// 显式声明 protectionClass 是为了让下面「必须清空 Cookie」的断言有对比基准。
|
||||
if (!VALID_PROTECTION_CLASSES.has(expect.protectionClass)) {
|
||||
fail(
|
||||
`${context} play_session_gateway protectionClass 不支持: ${expect.protectionClass}`,
|
||||
);
|
||||
}
|
||||
if (hasOwn(expect, 'upstreamPath')) {
|
||||
fail(`${context} play_session_gateway 不做路径重写,不能配置 upstreamPath。`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasOwn(expect, 'protectionClass')) {
|
||||
fail(`${context} 非 proxy 路由不能配置 protectionClass。`);
|
||||
}
|
||||
@@ -238,6 +254,7 @@ function validateRustTestUsesMatrix() {
|
||||
'fn is_main_spa_path(path: &str)',
|
||||
"path.strip_suffix('/')",
|
||||
'normalized.eq_ignore_ascii_case(candidate)',
|
||||
'("play_session_gateway", RouteDecision::PlaySessionGateway)',
|
||||
]) {
|
||||
if (!pingoraGatewaySource.includes(fragment)) {
|
||||
fail(`Pingora Rust 路由 parity 测试缺少矩阵接入片段: ${fragment}`);
|
||||
@@ -245,6 +262,109 @@ function validateRustTestUsesMatrix() {
|
||||
}
|
||||
}
|
||||
|
||||
// 播放会话前缀的 Pingora 侧判定:必须独立成 `PlaySessionGateway`,在通用 `/api` 分支之前命中,
|
||||
// 并通过 `route_clears_cookie` 在上游代理阶段清空 Cookie。把前缀合并回通用 `/api` 分支(或删掉
|
||||
// 清 Cookie 的处理)都会让这条断言失败。
|
||||
function validateRustPlaySessionGatewayIsolation() {
|
||||
for (const fragment of [
|
||||
'fn is_play_session_proxy_path(path: &str) -> bool',
|
||||
'"/api/game-distribution/play-sessions/"',
|
||||
'fn route_clears_cookie(route: &RouteDecision) -> bool',
|
||||
'upstream_request.remove_header("cookie");',
|
||||
]) {
|
||||
if (!pingoraGatewaySource.includes(fragment)) {
|
||||
fail(`Pingora Rust 缺少播放会话 Cookie 隔离实现: ${fragment}`);
|
||||
}
|
||||
}
|
||||
|
||||
const classifyBlock = pingoraGatewaySource.match(
|
||||
/fn classify_path\(path: &str\) -> RouteDecision \{([\s\S]*?)\n\}/u,
|
||||
);
|
||||
if (!classifyBlock) {
|
||||
fail('Pingora Rust 缺少 classify_path 路由判定函数。');
|
||||
return;
|
||||
}
|
||||
|
||||
const playSessionIndex = classifyBlock[1].indexOf(
|
||||
'if is_play_session_proxy_path(path) {',
|
||||
);
|
||||
const genericApiIndex = classifyBlock[1].indexOf('path == "/api" || path.starts_with("/api/")');
|
||||
if (playSessionIndex < 0) {
|
||||
fail(
|
||||
'Pingora classify_path 缺少播放会话前缀判定(必须在通用 /api 分支之前命中)。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (genericApiIndex < 0) {
|
||||
fail('Pingora classify_path 缺少通用 /api 代理分支。');
|
||||
return;
|
||||
}
|
||||
if (playSessionIndex > genericApiIndex) {
|
||||
fail(
|
||||
'Pingora classify_path 的播放会话前缀判定必须排在通用 /api 分支之前。',
|
||||
);
|
||||
}
|
||||
|
||||
if (!pingoraGatewaySource.includes('if route_clears_cookie(&ctx.route) {')) {
|
||||
fail(
|
||||
'Pingora 上游代理阶段必须按 route_clears_cookie 清空 Cookie(发行入口 / 播放会话入口同口径)。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 平台内容网关(发行入口 / 播放会话入口)必须在边缘清空 Cookie:api-server 侧对带平台
|
||||
// refresh Cookie 的请求返回 403(纵深防御保留),Cookie 一旦被转发,sandbox iframe 与包内
|
||||
// 每个相对资源都会 403,付费游戏实际不可玩(2026-10-05 就是这样被发现的)。
|
||||
// 这条同时挡住「把播放会话前缀合并回通用 `/api` 规则」:通用规则必须继续转发 Cookie
|
||||
// (`/api/auth/*` 依赖 refresh cookie),合并后要么清空 Cookie 的片段消失、要么落到通用 location。
|
||||
function validateContentGatewayCookieIsolation() {
|
||||
const clearCookieFragment = 'proxy_set_header Cookie "";';
|
||||
const genericApiLocation = 'location ~ ^/api(?:/|$)';
|
||||
const contentGatewayKinds = new Set(['release_gateway', 'play_session_gateway']);
|
||||
|
||||
for (const route of matrix.routes) {
|
||||
if (!contentGatewayKinds.has(route.expect?.kind)) {
|
||||
continue;
|
||||
}
|
||||
for (const environment of ['production', 'development']) {
|
||||
const fragments = route.nginx?.[environment] ?? [];
|
||||
if (!fragments.includes(clearCookieFragment)) {
|
||||
fail(
|
||||
`route ${route.id} 的 ${environment} Nginx 片段必须显式清空 Cookie:${clearCookieFragment}`,
|
||||
);
|
||||
}
|
||||
const mergedIntoTemplate = fragments.some((fragment) =>
|
||||
fragment.includes(genericApiLocation),
|
||||
);
|
||||
if (mergedIntoTemplate) {
|
||||
fail(
|
||||
`route ${route.id} 的 ${environment} Nginx 片段不能复用通用 ${genericApiLocation}(通用规则会转发 Cookie)。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 播放会话前缀必须排在自己的 `^~` 前缀 location 上,并且在模板里排在通用 `/api` location 之前;
|
||||
// nginx 的 `^~` 前缀优先于正则 location,但顺序仍按任务要求固定,便于人工核对。
|
||||
const playSessionLocation = 'location ^~ /api/game-distribution/play-sessions/';
|
||||
for (const environment of ['production', 'development']) {
|
||||
const source = files[environment];
|
||||
const playSessionIndex = source.indexOf(playSessionLocation);
|
||||
if (playSessionIndex < 0) {
|
||||
fail(
|
||||
`${environment} Nginx 模板缺少播放会话前缀 location: ${playSessionLocation}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const genericApiIndex = source.indexOf(genericApiLocation);
|
||||
if (genericApiIndex >= 0 && playSessionIndex > genericApiIndex) {
|
||||
fail(
|
||||
`${environment} Nginx 模板的播放会话前缀 location 必须排在通用 ${genericApiLocation} 之前。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 反向覆盖:模板里出现的每条 location 都必须被矩阵某条用例声明过。
|
||||
// 只做正向检查(矩阵片段存在于模板)会漏掉「Nginx 模板加/改了路由、矩阵与 Pingora 没跟上」——
|
||||
// 2026-09-29 的发行网关路由就是这样:Nginx 有三份、Pingora 和矩阵都没有,门禁一直是绿的。
|
||||
@@ -321,6 +441,8 @@ function validateRustMainSpaPrefixPaths() {
|
||||
|
||||
validateMatrixShape();
|
||||
validateRustTestUsesMatrix();
|
||||
validateRustPlaySessionGatewayIsolation();
|
||||
validateContentGatewayCookieIsolation();
|
||||
validateNginxLocationsAreCovered();
|
||||
validateRustMainSpaRoutes();
|
||||
validateRustMainSpaPrefixPaths();
|
||||
|
||||
Reference in New Issue
Block a user