Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions docs/strategy_switch_architecture_security_review.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。

Expand Down Expand Up @@ -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`

19 changes: 15 additions & 4 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,22 +24,33 @@ 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",
headers: { Origin: "https://evil.example" },
}),
{ 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(
[
Expand Down
13 changes: 10 additions & 3 deletions web/strategy-switch-console/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down