修复付费游戏播放会话在边缘转发 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

- 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:
2026-10-05 17:53:51 +08:00
parent 054709db65
commit d40df89e2c
14 changed files with 562 additions and 10 deletions
+69
View File
@@ -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');
+68
View File
@@ -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',
+122
View File
@@ -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();