Skip to content

Commit ffa4b3c

Browse files
authored
Harden and polish strategy switch console (#21)
1 parent 2045064 commit ffa4b3c

5 files changed

Lines changed: 279 additions & 65 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# 策略切换控制台架构与安全 Review
2+
3+
日期:2026-06-09
4+
5+
## 摘要
6+
7+
当前方案适合个人量化系统:公开页面只读,GitHub OAuth 通过后才允许触发 Worker,真正写 GitHub Variables 的动作仍由 `Manual Strategy Switch` workflow 执行。这个边界比“网页密码 + 前端 token”安全,也比大型审批后台简单。
8+
9+
本次 review 没发现需要停止发布的 Critical/High 问题。已补上基础安全响应头、状态变更请求的同源 `Origin` 要求,并把主切换页动态渲染改成 DOM API。
10+
11+
## 当前架构理解
12+
13+
- 前端页面在 [web/strategy-switch-console/index.html](/home/ubuntu/Projects/QuantRuntimeSettings/web/strategy-switch-console/index.html:580) 提供四个平台的账号、策略、模式选择。
14+
- Worker 在 [web/strategy-switch-console/worker.js](/home/ubuntu/Projects/QuantRuntimeSettings/web/strategy-switch-console/worker.js:47) 负责路由、GitHub OAuth、session、allowlist/admin 校验、账号配置读取和 workflow dispatch。
15+
- workflow 在 [.github/workflows/manual-strategy-switch.yml](/home/ubuntu/Projects/QuantRuntimeSettings/.github/workflows/manual-strategy-switch.yml:125) 使用 `runtime-strategy-switch` environment,并只给 job `contents: read`
16+
- 真正的跨平台写入使用 GitHub Actions secret `RUNTIME_SETTINGS_GH_TOKEN`,Worker 只持有 dispatch token。
17+
18+
## 已处理发现
19+
20+
### F-001:安全响应头此前没有在 Worker 代码中显式设置
21+
22+
Severity:Medium
23+
24+
Location:[web/strategy-switch-console/worker.js](/home/ubuntu/Projects/QuantRuntimeSettings/web/strategy-switch-console/worker.js:29)
25+
26+
Evidence:现在所有 HTML、JSON、redirect 响应都会经过 `responseHeaders()`,并带上:
27+
28+
```js
29+
"frame-ancestors 'none'"
30+
"X-Content-Type-Options": "nosniff"
31+
"X-Frame-Options": "DENY"
32+
```
33+
34+
Impact:开源项目的公开 Worker 页面如果缺少 clickjacking、nosniff、referrer 等防护,浏览器侧暴露面更大。
35+
36+
Fix:新增 `SECURITY_HEADERS`,并在 [responseHeaders](/home/ubuntu/Projects/QuantRuntimeSettings/web/strategy-switch-console/worker.js:1444) 统一应用。
37+
38+
False positive notes:如果 Cloudflare 账号侧也配置了同类 header,这是 defense-in-depth,不冲突。
39+
40+
### F-002:状态变更 POST 现在要求同源 Origin
41+
42+
Severity:Medium
43+
44+
Location:[web/strategy-switch-console/worker.js](/home/ubuntu/Projects/QuantRuntimeSettings/web/strategy-switch-console/worker.js:162)[dispatchSwitch](/home/ubuntu/Projects/QuantRuntimeSettings/web/strategy-switch-console/worker.js:555)
45+
46+
Evidence:
47+
48+
```js
49+
requireSameOrigin(request, { requireOrigin: true });
50+
```
51+
52+
Impact:`/api/switch``/api/admin/config``/api/logout` 都是状态变更路径。要求浏览器 POST 带同源 `Origin`,能减少 cookie-auth endpoint 被跨站触发的空间。
53+
54+
Fix:`requireSameOrigin()` 现在支持 `requireOrigin`,缺失或跨站 Origin 都会拒绝。OAuth GET callback 不受影响。
55+
56+
False positive notes:非浏览器脚本如果手动带 session cookie 调 POST,也必须提供正确 `Origin` header。
57+
58+
### F-003:主切换页不再用 innerHTML 渲染配置数据
59+
60+
Severity:Low/Medium
61+
62+
Location:[web/strategy-switch-console/index.html](/home/ubuntu/Projects/QuantRuntimeSettings/web/strategy-switch-console/index.html:1106)
63+
64+
Evidence:平台按钮、账号下拉、策略下拉、摘要列表现在使用 `document.createElement()``textContent``new Option()``replaceChildren()`
65+
66+
Impact:账号和策略目录未来会继续动态化,减少 HTML 字符串拼接能降低 DOM XSS 误用风险。
67+
68+
Fix:主切换页删除了 `.innerHTML` 动态渲染路径,并在 [tests/strategy_switch_worker_validation.mjs](/home/ubuntu/Projects/QuantRuntimeSettings/tests/strategy_switch_worker_validation.mjs:12) 加了回归检查。
69+
70+
## 主要设计压力点
71+
72+
- CSP 仍然需要 `script-src 'unsafe-inline'``style-src 'unsafe-inline'`,因为当前页面和管理页都是单文件内联脚本/样式。对个人 Worker 可接受;如果后续多人使用,建议把脚本/样式拆成静态模块或引入 nonce/hash。
73+
- 代码中没有应用级 rate limit。GitHub OAuth、allowlist、Cloudflare 平台本身能挡住主要风险;如果 Worker 域名公开传播,建议在 Cloudflare 侧给 `/login``/callback``/api/switch` 配轻量限流。
74+
- session 是 Worker 内的 HMAC 签名 cookie,不是服务端 session store。当前 cookie 只存 login、orgs、exp,不存 secret,且每次读取会重新校验最新 auth config。保持 `SESSION_SECRET` 足够长并定期轮换即可。
75+
76+
## 推荐方案
77+
78+
- 保持 Worker 作为“登录、权限、参数校验、dispatch”边界。
79+
- 保持 workflow 作为“preview、确认词、GitHub Variables 写入、平台同步”边界。
80+
- 账号配置继续只放 route/account selector/service name,不放 broker 密码、token、API key。
81+
- 新增平台或策略时继续走 `strategy_profile``domain``supported_domains` 目录规范,这样页面和 Worker 校验会自动收敛。
82+
83+
## 不推荐方案
84+
85+
- 不建议用网页密码替代 GitHub OAuth。密码方案需要自己处理哈希、轮换、暴力尝试、泄漏响应,收益不大。
86+
- 不建议把高权限 token 放前端。开源项目里前端代码和网络请求都无法保密。
87+
- 不建议让 Worker 直接写四个平台仓库的变量。现在让 workflow 写入,审计、确认词、回滚路径都更清楚。
88+
89+
## 验证策略
90+
91+
- `node --experimental-default-type=module tests/strategy_switch_worker_validation.mjs`
92+
- `sed -n '/<script>/,/<\/script>/p' web/strategy-switch-console/index.html | sed '1d;$d' | node --check --input-type=commonjs`
93+
- `node --check --input-type=module < web/strategy-switch-console/page_asset.js`
94+
- `node --check --input-type=module < web/strategy-switch-console/strategy_profiles_asset.js`
95+
- `node --check --input-type=module < web/strategy-switch-console/worker.js`
96+
- `python3 scripts/runtime_settings.py validate`
97+
- `python3 -m unittest discover -s tests -v`
98+

tests/strategy_switch_worker_validation.mjs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,37 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
99
const indexHtml = readFileSync(resolve(root, "web/strategy-switch-console/index.html"), "utf8");
1010
const renderPlatformsBody = indexHtml.match(/function renderPlatforms\(\) \{([\s\S]*?)\n \}/)?.[1] || "";
1111
assert.ok(!renderPlatformsBody.includes("syncStrategyForAccount("));
12+
assert.equal(indexHtml.includes(".innerHTML"), false);
13+
14+
const headers = __test.responseHeaders({ "Content-Type": "text/html; charset=utf-8" });
15+
assert.equal(headers.get("X-Frame-Options"), "DENY");
16+
assert.equal(headers.get("X-Content-Type-Options"), "nosniff");
17+
assert.equal(headers.get("Referrer-Policy"), "no-referrer");
18+
assert.match(headers.get("Content-Security-Policy") || "", /frame-ancestors 'none'/);
19+
20+
assert.doesNotThrow(() => __test.requireSameOrigin(
21+
new Request("https://switch.example/api/switch", {
22+
method: "POST",
23+
headers: { Origin: "https://switch.example" },
24+
}),
25+
{ requireOrigin: true },
26+
));
27+
assert.throws(
28+
() => __test.requireSameOrigin(new Request("https://switch.example/api/switch", { method: "POST" }), {
29+
requireOrigin: true,
30+
}),
31+
/Origin header is required/,
32+
);
33+
assert.throws(
34+
() => __test.requireSameOrigin(
35+
new Request("https://switch.example/api/switch", {
36+
method: "POST",
37+
headers: { Origin: "https://evil.example" },
38+
}),
39+
{ requireOrigin: true },
40+
),
41+
/cross-origin request rejected/,
42+
);
1243

1344
const strategyProfiles = __test.normalizeStrategyProfilesPayload(
1445
[

0 commit comments

Comments
 (0)