diff --git a/docs/strategy_switch_architecture_security_review.zh-CN.md b/docs/strategy_switch_architecture_security_review.zh-CN.md index 0b9d612..7ac8c4f 100644 --- a/docs/strategy_switch_architecture_security_review.zh-CN.md +++ b/docs/strategy_switch_architecture_security_review.zh-CN.md @@ -51,7 +51,7 @@ requireSameOrigin(request, { requireOrigin: true }); Impact:`/api/switch`、`/api/admin/config`、`/api/logout` 都是状态变更路径。要求浏览器 POST 带同源 `Origin`,能减少 cookie-auth endpoint 被跨站触发的空间。 -Fix:`requireSameOrigin()` 现在支持 `requireOrigin`,缺失或跨站 Origin 都会拒绝。OAuth GET callback 不受影响。 +Fix:`requireSameOrigin()` 现在支持 `requireOrigin`,缺失或跨站 Origin 都会以 403 拒绝。OAuth GET callback 不受影响。 False positive notes:非浏览器脚本如果手动带 session cookie 调 POST,也必须提供正确 `Origin` header。 @@ -95,4 +95,3 @@ Fix:主切换页删除了 `.innerHTML` 动态渲染路径,并在 [tests/stra - `node --check --input-type=module < web/strategy-switch-console/worker.js` - `python3 scripts/runtime_settings.py validate` - `python3 -m unittest discover -s tests -v` - diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index 801fe08..748ac72 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -24,13 +24,14 @@ assert.doesNotThrow(() => __test.requireSameOrigin( }), { requireOrigin: true }, )); -assert.throws( +const missingOriginError = captureError( () => __test.requireSameOrigin(new Request("https://switch.example/api/switch", { method: "POST" }), { requireOrigin: true, }), - /Origin header is required/, ); -assert.throws( +assert.match(missingOriginError.message, /Origin header is required/); +assert.equal(missingOriginError.status, 403); +const crossOriginError = captureError( () => __test.requireSameOrigin( new Request("https://switch.example/api/switch", { method: "POST", @@ -38,8 +39,18 @@ assert.throws( }), { requireOrigin: true }, ), - /cross-origin request rejected/, ); +assert.match(crossOriginError.message, /cross-origin request rejected/); +assert.equal(crossOriginError.status, 403); + +function captureError(fn) { + try { + fn(); + } catch (error) { + return error; + } + assert.fail("Expected function to throw"); +} const strategyProfiles = __test.normalizeStrategyProfilesPayload( [ diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index bd50023..ebb0ba0 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -62,11 +62,18 @@ export default { if (url.pathname === "/api/switch" && request.method === "POST") return dispatchSwitch(request, env); return html(PAGE_HTML); } catch (error) { - return json({ ok: false, error: error.message || "unexpected error" }, 500); + return json({ ok: false, error: error.message || "unexpected error" }, error.status || 500); } }, }; +class HttpError extends Error { + constructor(message, status) { + super(message); + this.status = status; + } +} + async function startLogin(request, env) { requireEnv(env, "GITHUB_CLIENT_ID"); const url = new URL(request.url); @@ -868,10 +875,10 @@ function cleanLabel(value, field) { function requireSameOrigin(request, options = {}) { const origin = request.headers.get("Origin"); if (!origin) { - if (options.requireOrigin) throw new Error("Origin header is required"); + if (options.requireOrigin) throw new HttpError("Origin header is required", 403); return; } - if (origin !== new URL(request.url).origin) throw new Error("cross-origin request rejected"); + if (origin !== new URL(request.url).origin) throw new HttpError("cross-origin request rejected", 403); } async function fetchGithubVariable(token, repository, scope, githubEnvironment, name) {