feat(worker): safelist 纳入 push/register 与 push/disable

网关白名单此前无条件 fail-closed,后端注册的 18 条路由中只有
POST /api/push/register 与 POST /api/push/disable 两条需要业务流量却未
被转发,导致 PWA 订阅/退订必须绕过网关直连后端。

- SAFE_ROUTES 12 → 14 条(backends.js:9-24)
- ROUTE_METHODS 同步补两条 POST(index.js:40-55)
- 头注释路由清单同步
- backends.test.js 转发集合断言补齐两条
- index.test.js 新增转发端到端用例(含鉴权头透传路径)

单入口边界:两条路由仍经 SAFE_ROUTES 无条件校验,未放宽白名单语义。
This commit is contained in:
anonsaber 2026-09-28 10:02:18 +08:00
parent e35cc64ce3
commit a39ebcf9d1
4 changed files with 31 additions and 1 deletions

View File

@ -17,6 +17,8 @@ export const SAFE_ROUTES = Object.freeze([
"/api/admin/session/revoke", "/api/admin/session/revoke",
"/webhook/tg", "/webhook/tg",
"/push/jmap", "/push/jmap",
"/api/push/register",
"/api/push/disable",
"/reconcile", "/reconcile",
"/ready", "/ready",
]); ]);

View File

@ -16,7 +16,7 @@
* - GET|PUT /api/config、PUT /api/business-config、POST /api/admin/session[/revoke] * - GET|PUT /api/config、PUT /api/business-config、POST /api/admin/session[/revoke]
* → 透传至后端;后端校验 bootstrap 凭据或短期 admin session * → 透传至后端;后端校验 bootstrap 凭据或短期 admin session
* - GET /healthz → LB 聚合健康(MOD-HEALTH-AGG) * - GET /healthz → LB 聚合健康(MOD-HEALTH-AGG)
* - POST /webhook/tg|/push/jmap|/reconcile、GET /ready → 透传 + 有界故障转移 * - POST /webhook/tg|/push/jmap|/api/push/register|/api/push/disable|/reconcile、GET /ready → 透传 + 有界故障转移
* - 其它 → 404 / 405 * - 其它 → 404 / 405
* *
* 日志红线:仅打「方法 / 路径 / origin / 失败类别」,绝不打印 header/body/secret。 * 日志红线:仅打「方法 / 路径 / origin / 失败类别」,绝不打印 header/body/secret。
@ -48,6 +48,8 @@ const ROUTE_METHODS = Object.freeze({
"/api/admin/session/revoke": ["POST"], "/api/admin/session/revoke": ["POST"],
"/webhook/tg": ["POST"], "/webhook/tg": ["POST"],
"/push/jmap": ["POST"], "/push/jmap": ["POST"],
"/api/push/register": ["POST"],
"/api/push/disable": ["POST"],
"/reconcile": ["POST"], "/reconcile": ["POST"],
"/ready": ["GET"], "/ready": ["GET"],
}); });

View File

@ -45,6 +45,7 @@ test("BACKEND_ORIGINS_JSON parses, dedups, validates all entries", () => {
test("route safelist excludes /healthz and unknown paths (C-LB-SINGLE-REG-URL)", () => { test("route safelist excludes /healthz and unknown paths (C-LB-SINGLE-REG-URL)", () => {
for (const r of [ for (const r of [
"/", "/assets/config.js", "/assets/styles.css", "/api/status", "/ready", "/webhook/tg", "/push/jmap", "/reconcile", "/", "/assets/config.js", "/assets/styles.css", "/api/status", "/ready", "/webhook/tg", "/push/jmap", "/reconcile",
"/api/push/register", "/api/push/disable",
"/api/config", "/api/business-config", "/api/admin/session", "/api/admin/session/revoke", "/api/config", "/api/business-config", "/api/admin/session", "/api/admin/session/revoke",
]) { ]) {
assert.ok(LB_ROUTES.includes(r), `forwarded: ${r}`); assert.ok(LB_ROUTES.includes(r), `forwarded: ${r}`);

View File

@ -131,3 +131,28 @@ test("Worker: valid env, GET /ready forwarded to backend with auth header intact
restore(); restore();
} }
}); });
test("Worker: POST /api/push/register and /api/push/disable are forwarded", async () => {
const seen = [];
const restore = stubFetch(async (url, init) => {
seen.push([url, init.method]);
return new Response("{\"error\":\"invalid_request\",\"request_id\":\"x\"}", {
status: 400,
headers: { "content-type": "application/json" },
});
});
try {
for (const path of ["/api/push/register", "/api/push/disable"]) {
const res = await handleFetch(
new Request(`https://lb.example${path}`, { method: "POST", body: "{}" }),
makeEnv(),
);
assert.equal(res.status, 400);
assert.deepEqual(await res.json(), { error: "invalid_request", request_id: "x" });
}
assert.equal(seen.length, 2);
assert.ok(seen.every(([url, method]) => method === "POST"));
} finally {
restore();
}
});