docs(阶段3): 修复波复评通过,安全 findings 收口

8 条(F1-F6 + 收尾 F4b/F5b)全部 ADDRESSED,修复 diff 内无新引入破坏。

复评补上了控制方没验充分的 F4b:真造了一条比赛提交,确认数据库存真值
而 API 返回给本人的是 info:{} / ip:null / contestId:null。

另核实 sampleUser() 是真正的默认关闭开关(覆盖全部 14 个下发点)、
isRegularUser 全仓确实只有一个调用点、.env 加载器优先级正确、
限流参数与旧后端 options/options.py 逐值吻合。

评审产物从 gitignore 的 .superpowers/sdd/ 归档进 docs/specs/ 以免丢失。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 06:26:20 -06:00
parent f548aef9f0
commit cb6d42363b
3 changed files with 602 additions and 0 deletions

View File

@@ -4,6 +4,22 @@
来源:`phase3-review-authz.md`(权限边界)+ `phase3-review-leakage.md`(数据泄露)
受审代码commit `8c00cdc`oj 侧 65 个端点
> ## 状态已全部修复并复评通过2026-08-07 收口)
>
> 修复提交:`b4b61af` / `8237909` / `b7adf29` / `f548aef`
> 复评报告:`phase3-review-rereview.md` —— 8 条F1-F6 + 收尾的 F4b、F5b**全部 ADDRESSED**
> 修复 diff 内无新引入破坏。
>
> 复评补上了控制方没验充分的一条:**F4b 的 `contestId`**。控制方当时用的提交本就不属于比赛,
> `contestId` 天然为 null运行时证据不成立。复评真造了一条比赛提交`contestId=7`、`ip` 有值),
> 确认数据库存的是真值、而 API 返回给提交者本人的是 `info:{}` / `ip:null` / `contestId:null`。
>
> 复评另核实:`sampleUser()` 是真正的默认关闭开关(覆盖全部 14 个下发点,含本清单未列的
> rankings 与题目列表/详情);`isRegularUser` 全仓确实只有一个调用点;`.env` 加载器的优先级
> 正确(真实环境变量 > cwd 的 .env > 仓库根 .env畸形行不崩、缺文件静默跳过限流参数
> `fill_rate=0.03` 与旧后端 `options/options.py` 逐值吻合,且拦截位置与
> `submission/views/oj.py` 一致(比赛权限校验之后、取题目之前)。
## 合并说明
两份评审独立进行、互不知情,却各自命中了同两条问题(`/profiles/:username` 匿名可读、

View File

@@ -0,0 +1,433 @@
# 阶段 3 修复报告
日期2026-08-07
需求文档:`docs/specs/phase3-fix-list.md`(两份评审合并的 6 条 findings
受修代码:`apps/api`Hono + Drizzle基线 commit `9c04b00`
提交:
| 短 SHA | 标题 | 覆盖 |
|---|---|---|
| `b4b61af` | fix(阶段3): 匿名不可读用户档案,真名改为默认不下发 | F1、F2 |
| `8237909` | fix(阶段3): 提交可见性守卫补上匿名,详情脱敏,提交接口加限流 | F3、F4、F6 |
| `b7adf29` | fix(阶段3): 去掉判题机 token 的弱默认值 | F5 |
验证环境postgres `:5433`、redis `:6380`、judge `:8081`API 跑在 `:3000``bun --watch`,改完自动重载)。
测试账号 `e2etest`/`Test123456`Regular User`student`Regular User真名 `Phase 2 Student`)、
`devadmin`Super Admin
`cd apps/api && bunx tsc --noEmit`**0 错误**
14 条路径 × {匿名, 登录} 冒烟 → **无 5xx**
---
## F1 —— 匿名可读任意用户完整档案【Critical】
**改法**`apps/api/src/routes/account.ts:101` 的 handler 开头加登录判断,未登录返回空。
对齐旧后端 `OnlineJudge/account/views/oj.py:40``UserProfileAPI.get` 首行
`if not user.is_authenticated: return self.success()`
`services/profile.ts` **没动** —— 它已有 `showRealName` 形参,且调用方传的是
`c.get("user")?.id === target.id`(只有看自己的档案才给真名),这一点与旧后端
`UserProfileSerializer(profile, show_real_name=show_real_name)` 的语义一致。
**实跑对比**(为让证据可读,临时把 `e2etest``real_name` 设为 `张三-测试`,测后已还原为 `NULL`
修复前:
```
### F1 anon GET /api/profiles/e2etest
status 200 -> {"data":{"id":2,"user":{"id":4,"username":"e2etest","email":"e2e@local.test",
"adminType":"Regular User","problemPermission":"None","createTime":"2026-08-07 07:19:10.789+00",
"lastLogin":"2026-08-07 07:50:40.139+00","openApi":false,"isDisabled":false,"className":null},
"realName":null,"acmProblemsStatus":{"problems":{"5":{"_id":"1004","status":0}}},...
### F1 logged-in (self) GET /api/profiles/e2etest
status 200 email: e2e@local.test realName: 张三-测试
```
修复后:
```
### F1 anon GET /api/profiles/e2etest
status 200 -> {"data":null}
### F1 logged-in (self) GET /api/profiles/e2etest
status 200 email: e2e@local.test realName: 张三-测试
```
匿名拿到空,登录看自己的档案不受影响。
**前端影响:无。** `apps/web/src/shared/api.ts:28` 已经有
`if (response.data === null) return { error: null, data: null }` 的分支,天然兼容。
---
## F2 —— 学生真名无条件下发【Critical】
**改法(结构性,不是逐处删字段)**`apps/api/src/routes/helpers.ts` 新增序列化层:
```ts
export function sampleUser(
source: { id: number; username: string },
realName: string | null | undefined,
options: { includeRealName?: boolean } = {},
): SampleUser
```
`realName` 默认不下发,需要的地方显式传 `{ includeRealName: true }`。这是旧后端
`OnlineJudge/utils/api/_serializers.py``UsernameSerializer(need_real_name=False)`
同一套约定:全仓 11 处调用只有 `contest/serializers.py:84` 一处显式打开。
函数上写了注释,说明「所有下发用户对象的地方都必须走这个函数,不要再手写
`{ id, username, realName }`」——目的就是让后面 admin 侧 45 个端点铺开时不会重犯。
### 13 个下发点清单及处置
| # | 位置 | 端点 | 匿名可达 | 处置 |
|---|---|---|---|---|
| 1 | `routes/account.ts:168` | `GET /rankings/users` | 是 | `sampleUser()`,关 |
| 2 | `routes/problem.ts:86``listItem()` | `GET /problems``GET /problems/:displayId/similar` | 是 | `sampleUser()`,关 |
| 3 | `routes/problem.ts:333` | `GET /problems/:displayId` | 是 | 原本就写死 `null`;改为走 `sampleUser()` 统一入口 |
| 4 | `routes/contest.ts:34``creator()` | `GET /contests``GET /contests/:id` | 是 | `sampleUser()`,关 |
| 5 | `routes/contest.ts:131` | `GET /contests/:id/problems` | 否(比赛权限) | `sampleUser()`,关 |
| 6 | `routes/contest.ts:177` | `GET /contests/:id/problems/:displayId` | 否(比赛权限) | `sampleUser()`,关 |
| 7 | **`routes/contest.ts:209`** | `GET /contests/:id/rank` | 否 | **`{ includeRealName: admin }`,保留** —— 唯一打开的一处 |
| 8 | `routes/content.ts:43` | `GET /announcements` | 是 | `sampleUser()`,关 |
| 9 | `routes/content.ts:64` | `GET /announcements/:id` | 是 | `sampleUser()`,关 |
| 10 | `routes/content.ts:85` | `GET /messages` | 否(`requireAuth` | `sampleUser()`,关 |
| 11 | `routes/content.ts:196` | `GET /tutorials/:id` | 是 | `sampleUser()`,关 |
| 12 | `routes/problemset.ts:62``problemSetCreator()` | `GET /problemsets``GET /problemsets/:id` | 是 | `sampleUser()`,关 |
| 13 | `routes/problemset.ts:169` | `GET /problemsets/:id/problems` | 是 | `sampleUser()`,关 |
| 14 | `routes/problemset.ts:398` | 题单进度列表 | 否(教师及以上) | `sampleUser()`,关。旧后端 `problemset/serializers.py:248` 同样是默认关 |
清单比文档说的「13 个」多一条:文档大概把 `problem.ts:333`(本来就写死 `null`)没计入,
或者把 `problem.ts:86` 服务的两个端点算成一个。**14 处全部收口,无遗漏**
```
$ grep -rn "realName:" apps/api/src/routes/*.ts | grep -v sampleUser \
| grep -v "realName: schema.userProfile.realName" | grep -v "string | null"
account.ts:94: realName: null, ← 注册时写库的 insert values不是下发点
helpers.ts:23: realName: options.includeRealName === true ? (realName ?? null) : null,
```
`services/profile.ts:29``realName` 由 F1 的 `showRealName` 形参把关(只有看自己的档案才给),
与旧后端 `UserProfileSerializer` 一致,未改。
**实跑对比**(修复前用 `git stash` 把改动摘掉实测,非静态推断):
修复前:
```
--- announcements (anon) ---
[{"id":2,"username":"student","realName":"Phase 2 Student"}]
--- contests createdBy (anon) ---
[{"id":4,"username":"e2etest","realName":"张三-测试"}]
--- contest rank as CONTEST ADMIN (e2etest, creator) ---
[{"id":2,"username":"student","realName":"Phase 2 Student"}]
--- contest rank as NON-admin (student) ---
[{"id":2,"username":"student","realName":null}]
--- contest detail createdBy (anon) ---
{"id":4,"username":"e2etest","realName":"张三-测试"}
### 匿名 GET /api/rankings/users
[{"id":4,"username":"e2etest","realName":"张三-测试"},{"id":2,"username":"student","realName":"Phase 2 Student"}]
```
修复后:
```
--- announcements (anon) ---
[{"id":2,"username":"student","realName":null}]
--- contests createdBy (anon) ---
[{"id":4,"username":"e2etest","realName":null}]
--- contest rank as CONTEST ADMIN (e2etest, creator) ---
[{"id":2,"username":"student","realName":"Phase 2 Student"}] ← 仍然有,符合预期
--- contest rank as NON-admin (student) ---
[{"id":2,"username":"student","realName":null}]
--- contest detail createdBy (anon) ---
{"id":4,"username":"e2etest","realName":null}
### 匿名 GET /api/rankings/users
[{"id":2,"username":"student","realName":null},{"id":4,"username":"e2etest","realName":null}]
### 匿名 GET /api/problems (createdBy)
[{"id":1,"username":"devadmin","realName":null},{"id":1,"username":"devadmin","realName":null}]
```
比赛榜单对**比赛管理员**(这里是比赛创建者 `e2etest`)仍然下发真名,对非管理员仍然是 `null` —— 行为未变。
**前端影响**`apps/web` 只在 `oj/api.ts:86``shared/api.ts:37,47``realName`,都做了
`?? ""` 的兜底,字段变 `null` 不会炸。真名本来也不该在这些位置显示。
---
## F3 —— 匿名绕过提交可见性守卫【Critical】
**改法**`routes/submission.ts:220``isRegularUser(user)` 改为 `!isAdminRole(user)`
(非管理员即受限,匿名落入受限分支)。
**`isRegularUser` 的其他调用点:查过了,全仓只有这一处。**
```
$ grep -rn "isRegularUser" --include=*.ts apps/ packages/
apps/api/src/routes/submission.ts:31 import
apps/api/src/routes/submission.ts:211 (唯一调用点,即本 finding
apps/api/src/routes/helpers.ts:27 (定义)
```
既然零个其他调用点,**把 `isRegularUser` 整个删掉**,原地留了一条注释说明为什么不要再加回来。
留着一个对 null 返回 false 的「是普通用户才受限」判定,就是给下一个人准备的坑。
(这一步超出了「只改 findings」的字面范围但属于 F3 的根因,不是顺手重构。)
**实跑对比**(临时把 `options_sysoptions``submission_list_show_all` 置 false
**该行原本不存在,测后已 DELETE**
修复前:
```
### F3 GET /api/submissions with submission_list_show_all=false
anon total = 23 ← 全部可见
student total = 0 ← 被正确限制
```
修复后:
```
### F3 GET /api/submissions with submission_list_show_all=false
anon total = 0
student total = 0
```
还原核验:
```
$ bun db.ts delete → deleted rows: 1
$ bun db.ts show → [] (该 key 无行,回到原始状态)
```
---
## F4 —— 提交详情返回判题内部信息与 IP【Important】
**改法**`routes/submission.ts:195``const full = isAdminRole(user) || row.submission.userId === user.id`
改为 `const full = isAdminRole(user)`
依据旧后端 `OnlineJudge/submission/views/oj.py:100-104`
```python
if request.user.is_admin_role():
submission_data = ... SubmissionModelSerializer ... # fields = "__all__"
else:
submission_data = ... SubmissionSafeModelSerializer ... # exclude = ("info", "contest", "ip")
```
把关的是**角色**,不是「是不是自己的提交」。
**实跑对比**(用一条已判完、`info` 有内容的真实提交;`devadmin` 是 Super Admin
修复前(提交所有者 `e2etest`Regular User
```
### F4 own submission detail
id 0249d3606fab3e05f4926c45d00efdee | has info: {"err":null,"data":[{"error":0,"memory":7811072,
"output":null,"result":-1,"signal":0,"cpu_time":3,"exit_code":0,"real_time":8,
"test_case":"1","output_md5":"c4ca4238a0b923820dcc509a6f75849b"},{"error": ... | ip: null
```
修复后:
```
submission 0249d3606fab3e05f4926c45d00efdee
OWNER (Regular User) -> info: {} | ip: null | result: -1
ADMIN (Super Admin) -> info: {"err":null,"data":[{"error":0,"memory":7811072,"output":null,
"result":-1,"signal":0,"cpu_time":3,"exit_code":0,"real_time":8,"test_case":"1",
"output_md5":"c4ca4238a0b923820dcc509a6f75849b"},{"error":0,"memory":7749632, ... | ip: null
```
上面这条老提交的 `ip` 本来就是 `NULL`(本机请求没有 `X-Forwarded-For`
为了单独验证 `ip` 确实被挡住,另造了一条带 `X-Forwarded-For: 10.11.12.13` 的提交:
```
库里实际值: [{"id":"...","ip":"10.11.12.13","info_type":"object"}]
OWNER (Regular User) -> info: {} | ip: null
ADMIN (Super Admin) -> info: {} | ip: "10.11.12.13"
```
库里有值、学生看不到、管理员看得到 —— `ip` 是被脱敏而不是本来就空。
### 前端影响(这条确实波及前端)
`apps/web/src/oj/submission/detail.vue:149`
```html
<n-data-table
v-if="!hideList && submission.info && submission.info.data"
:columns="columns"
:data="submission.info.data"
/>
```
改完之后学生拿到 `info: {}``submission.info.data``undefined``v-if` 不成立,
**测试点结果表格不再渲染。不会报错,只是不显示。**
按要求仍按旧后端行为修。补充一点判断依据:**这不算功能回退**。旧后端 `SubmissionSafeModelSerializer`
本来就 `exclude=("info", ...)`,也就是说**生产环境的学生从来就没看到过这张表**——
`v-if` 的存在本身就是为这个场景写的。所以新后端这一版是「多给了」,现在改回去,
前端不需要适配,线上行为反而回到一致。
**另注意**:旧后端的 `SubmissionSafeModelSerializer` 还 exclude 了 `contest`
新后端仍无条件下发 `contestId`。这超出 F4 的字面范围(评审只点了 `info``ip`
本次**未改**,记在下方「疑虑」里。
---
## F5 —— 判题机 token 默认值硬编码【Important】
**改法**(三处):
1. `apps/api/src/config.ts`:抽出 `judgeServerToken()`env 缺失时 `randomBytes(32).toString("hex")`
`console.warn` 告警。对齐旧后端 `OnlineJudge/options/options.py:93`
```python
token = os.environ.get("JUDGE_SERVER_TOKEN")
return token if token else rand_str()
```
选「随机 fail-safe」而不是「启动失败」就是为了不把本地开发搞死 —— 服务照起,
只是判题机心跳被 403 挡掉,日志里有明显告警。
2. `docker/compose.dev.yml``${OJ2_JUDGE_TOKEN:-oj2-dev-token}` → `${OJ2_JUDGE_TOKEN:?...}`
未设置时 compose 直接报错退出。文件顶部加了本地怎么设的三行命令。
3. `.env.example``JUDGE_SERVER_TOKEN=` 留空 + 生成命令注释,说明
`JUDGE_SERVER_TOKEN`(后端读)与 `OJ2_JUDGE_TOKEN`(判题机容器读)名字不同但值必须一致。
**实跑对比**
后端 —— 修复前 `config.judgeServerToken` 恒为 `"oj2-dev-token"`;修复后:
```
--- no env (fail-safe random) ---
[config] JUDGE_SERVER_TOKEN 未设置,已生成一次性随机 token。判题机将无法通过鉴权
本地开发请在 .env 里设置 JUDGE_SERVER_TOKEN并让 docker/compose.dev.yml 的 OJ2_JUDGE_TOKEN 取同一个值。
token: 9acd77a3ea1bbd17... len=64
--- second run: different value (proves it is random, not a repo constant) ---
token: f2328363bcda0ad9...
--- with env set ---
token: real-token-from-env
```
两次运行值不同 → 确实是随机不是换了个仓库常量。env 存在时原样使用。
compose
```
--- compose without OJ2_JUDGE_TOKEN ---
error while interpolating services.judge.environment.TOKEN:
required variable OJ2_JUDGE_TOKEN is missing a value: 请先设置 OJ2_JUDGE_TOKEN见本文件顶部注释
--- compose with OJ2_JUDGE_TOKEN ---
TOKEN: abc123
```
(当前跑着的判题机容器是用旧的 `oj2-dev-token` 起的,本次没有重启它 ——
下次 `docker compose up` 之前需要按注释设一次 `OJ2_JUDGE_TOKEN` 与 `JUDGE_SERVER_TOKEN`。)
---
## F6 —— 提交接口缺少限流【Important】
**参数来自旧后端,不是拍脑袋定的**
- `OnlineJudge/utils/throttling.py` —— TokenBucket 算法
- `OnlineJudge/options/options.py:120` —— 默认参数
```python
throttling = {"ip": {"capacity": 100, "fill_rate": 0.1, "default_capacity": 50},
"user": {"capacity": 20, "fill_rate": 0.03, "default_capacity": 10}}
```
- `OnlineJudge/submission/views/oj.py:36-42` —— `SubmissionAPI.throttling` **只用 user 桶**
key 是 `str(request.user.id)``auth_method == "api_key"` 时直接跳过
**改法**:新增 `apps/api/src/services/throttling.ts`
- 同样只挂 user 桶、同样按 user id 做 key默认参数逐字照抄且和旧后端一样
**实际值以数据库 `throttling` 配置项为准**,缺失时才用默认值
- 落在 redis`throttling:user:<id>`TTL = `capacity/fill_rate + 60` 秒,每次调用刷新
- **改用 Lua 脚本做成原子操作**。旧实现自己在 docstring 里写明「对于单个 key 的操作不是线程安全的」,
而限流要挡的正是并发突发 —— 读改写有竞态等于没挡。算法与参数不变,只是把它做对
- 挂点位置与旧后端一致:比赛权限校验之后、取题目之前
- 超限返回 `429 too-many-submissions` + `Please wait N seconds`(旧后端文案
`"Please wait %d seconds" % int(wait)`)。为此 `http.ts` 的 `failure()` 状态码联合类型加了 `429`
**实跑对比**(同一账号连打 15 次 `POST /api/submissions`
修复前:
```
### F6 rapid POST /api/submissions x15
#1 201 #2 201 #3 201 #4 201 #5 201
#6 201 #7 201 #8 201 #9 201 #10 201
#11 201 #12 201 #13 201 #14 201 #15 201
```
15/15 全过,判题队列可以被一个脚本随便打满。
修复后:
```
### F6 rapid POST /api/submissions x15
#1 201 ... #9 201
#10 429:{"error":{"code":"too-many-submissions","message":"Please wait 23 seconds"}}
#11 429 ... #15 429
```
9 条通过后开始 429。桶初始 `default_capacity=10`,此前 F4 的验证提交已消耗 2 个、
期间回填约 1 个,落在 9 —— 与参数吻合。
---
## 改动文件清单
| 文件 | finding |
|---|---|
| `apps/api/src/routes/account.ts` | F1、F2 |
| `apps/api/src/routes/helpers.ts` | F2新增 `sampleUser`、F3删除 `isRegularUser` |
| `apps/api/src/routes/contest.ts` | F2 |
| `apps/api/src/routes/content.ts` | F2 |
| `apps/api/src/routes/problem.ts` | F2 |
| `apps/api/src/routes/problemset.ts` | F2 |
| `apps/api/src/routes/submission.ts` | F3、F4、F6 |
| `apps/api/src/services/throttling.ts` | F6新文件 |
| `apps/api/src/http.ts` | F6`failure()` 支持 429 |
| `apps/api/src/config.ts` | F5 |
| `docker/compose.dev.yml` | F5 |
| `.env.example` | F5 |
`apps/web` **一个字节没改**F1/F4 的前端影响见上文,均无需适配)。
---
## 纪律自查
- **旧仓库冻结**`OnlineJudge/` 与 `ojnext/` 的 `git status --short` 均为空,全程只读。
- **没写测试**:符合项目既定策略,验证一律走实跑 API。
- **没有范围蔓延**:唯一超出 findings 字面范围的是「删掉 `isRegularUser`」和
「`http.ts` 的 `failure()` 加 429」前者是 F3 的根因、后者是 F6 的必要条件,都在上面说明了。
- **数据库改动全部还原**
| 改动 | 还原 | 核验 |
|---|---|---|
| `e2etest.real_name` 设为 `张三-测试` | 改回 `NULL`(原值已知) | `[{"username":"student","real_name":"Phase 2 Student"},{"username":"e2etest","real_name":null}]` |
| `options_sysoptions` 插入 `submission_list_show_all=false`**该行原本不存在** | `DELETE` | `bun db.ts show` → `[]` |
| 造了 1 个比赛 + 1 条榜单行 + 1 条公告(原本 0/0/0 | 全部删除 | `contests: 0 \| ranks: 0 \| announcements: 0` |
| 限流/F4 测试产生 26 条提交 | 按快照删除,并把 `problem` 与 `user_profile` 的 `submission_number`/`accepted_number`/`statistic_info`/`acm_problems_status` 恢复到快照值 | `submissions now: 8 \| snapshot had: 8` |
| `devadmin` 密码临时改成探针口令(为了验证 F4 的管理员视角) | 用保存的原 hash 写回 | `restored devadmin password hash, identical: true \| raw_password: devonly` |
redis 里的 `throttling:*` 也清了(`deleted throttling keys: 1`),免得本地开发一上来就撞限流。
- **临时脚本全在 `/tmp` 的 scratchpad**,仓库里没留(`git status` 干净,只有 3 个提交)。
- **没有 drop/truncate 任何表。**
---
## 疑虑
1. **`contestId` 仍无条件下发**。旧后端的 `SubmissionSafeModelSerializer` 是
`exclude = ("info", "contest", "ip")` —— 三个字段,新后端只脱敏了两个。
评审 F4 只点了 `info` 与 `ip`,我按 finding 的字面范围修,没顺手动 `contestId`。
泄露面比另外两个小得多(只是一个比赛 id且比赛可见性另有把关但**严格说没对齐旧后端**。
建议下一轮一并处理,或明确判定新后端就是要下发它。
2. **限流的 429 状态码是新引入的语义**。旧后端所有错误都走 `self.error()` → HTTP 200 +
`{"error": ..., "data": null}` 信封;新后端用真实状态码。前端目前**没有**针对 429 的处理,
学生撞到限流时会走通用错误提示(能看到 `Please wait N seconds` 文案,但没有专门的 UI
不阻塞,但前端接线时值得单独确认一下。
3. **限流按 user id 计,匿名不涉及** —— 因为 `POST /submissions` 挂了 `requireAuth`。
旧后端的 ip 桶(`capacity 100, fill_rate 0.1`)在 `SubmissionAPI` 里同样没用上,
所以这里是对齐的,不是漏搬。但配置项里保留了 ip 桶的默认值,将来要挂匿名端点可以直接用。
4. **判题机容器还在用旧 token**。F5 改的是「以后」的行为,当前跑着的
`oj2-judge` 容器是之前用 `oj2-dev-token` 起的,本次没有重启它(重启会打断验证)。
下次 `docker compose -f docker/compose.dev.yml up` 之前必须先按注释设好
`OJ2_JUDGE_TOKEN` 与 `JUDGE_SERVER_TOKEN`(两者取同一个值),否则 compose 会直接报错。
5. **F2 的下发点我数出 14 处,文档说 13 处**。差异见上文表格的说明(大概率是
`problem.ts:333` 本来就写死 `null` 没计入)。已用 grep 反证收口完整,
但如果评审方手里有一份逐条清单,值得对一遍确认不是我漏看了某个反方向的差异。

View File

@@ -0,0 +1,153 @@
# 阶段 3 修复复评
日期2026-08-07
复评范围commit `9c04b00..f548aef`4 个提交),对照 `docs/specs/phase3-fix-list.md` 的 6 条 findings + 2 条收尾修复F4b、F5b
方法:读 diff + 读源码 + 独立实跑(不复用实施者的证据,除非明确说明)。所有测试数据均已清理并核对还原。
---
## 逐条核验
### F1 —— 匿名可读任意用户完整档案【Critical】→ **ADDRESSED**
`apps/api/src/routes/account.ts:101``optionalAuth` 之后立即 `if (!c.get("user")) return success(c, null)`,早于任何数据库查询。
独立实跑:
```
匿名 GET /api/profiles/e2etest -> 200 {"data":null}
```
`services/profile.ts``showRealName` 参数链路未动,登录看自己档案不受影响(未重复验证,逻辑未改,风险低)。
前端 `apps/web/src/shared/api.ts:28` 已有 `data === null` 分支,兼容确认(读码确认,未跑前端)。
### F2 —— 学生真名无条件下发【Critical】→ **ADDRESSED**(默认关闭开关,非逐处删字段)
见下方「重点判断 1」结论14 个下发点全部经过统一的 `sampleUser()`,默认 `realName: null`,唯一显式打开的是 `contest.ts:209`(比赛榜单,且区分 admin/非 admin
独立实跑(与 diff 里的证据不同的端点,避免只是复读实施者的话):
```
匿名 GET /api/rankings/users?limit=2 -> [{"username":"student","realName":null},{"username":"e2etest","realName":null}]
匿名 GET /api/problems?limit=2 createdBy -> {"id":1,"username":"devadmin","realName":null} (x2)
匿名 GET /api/problems/1002 createdBy -> {"id":1,"username":"devadmin","realName":null}
```
`grep -rn "realName" apps/api/src --include=*.ts` 复查:除 `sampleUser` 内部实现、`db.select()` 取列、`account.ts:94`(注册写库常量 null`services/profile.ts:29`F1 已核实的独立开关)外,没有任何路由手写 `{ ..., realName }` 对象绕过 `sampleUser()`
### F3 —— 匿名绕过提交可见性守卫【Critical】→ **ADDRESSED**
`submission.ts:227``!(await getBooleanOption(...)) && !isAdminRole(user)``isAdminRole(null)` 返回 `false``!false = true`,匿名正确落入受限分支。
独立实跑(临时把 `submission_list_show_all` 写为 `false`,测后 DELETE 该行,确认恢复为 `[]`
```
匿名 GET /api/submissions -> total = 0
登录学生 GET /api/submissions -> total = 0
```
两者一致地受限F3 描述的「匿名权限大于登录用户」的错位已消除。
### F4 —— 提交详情返回 info/ip【Important】→ **ADDRESSED**
`submission.ts:199``const full = isAdminRole(user)`,不再以 `row.submission.userId === user.id` 放行。`/submissions/:id``requireAuth`,匿名根本到不了这里,不存在新的匿名向量。
### F4b —— contestId 未脱敏 → **ADDRESSED已用真实比赛提交独立验证**(原报告只有代码审查,本次补上实跑)
`submission.ts:214``contestId: full ? row.submission.contestId : null`
独立实跑(造了一场临时比赛 + 一条挂在该比赛下的真实提交,验证后已删除,见下方清理记录):
```
DB 实际存储:{"contestId":7,"ip":"9.9.9.9"}
提交所有者 (Regular User) GET /api/submissions/<id> ->
{"info":{},"ip":null,"contestId":null, ...} ← 三个字段全部脱敏
```
`contestId` 确实是被脱敏成 `null`,而不是碰巧本来就是 `null`(数据库里明确存的是 `7`)。控制方文档里点名"没验充分"的这一条已经补齐。
### F5 —— 判题机 token 弱默认值【Important】→ **ADDRESSED**
`config.ts``judgeServerToken()`env 存在则用 env不存在则 `randomBytes(32)` + `console.warn``docker/compose.dev.yml``${OJ2_JUDGE_TOKEN:?...}` 缺失时 compose 直接报错退出(未重新实跑 compose读码 + 报告证据一致,逻辑简单,风险低)。
### F5b —— 仓库根 .env 读不到 → **ADDRESSED加载器安全**
见下方「重点判断 3」独立做了三组隔离测试shell env 优先级、cwd .env 优先级、缺文件不崩、畸形行不崩),并额外做了一次端到端真实提交(问题 1002Python3确认判题机用当前 `.env` 里的 token 正常认证、提交离开 PENDING/JUDGING拿到 result=-2非 PENDING/JUDGING证明判题机-后端握手成功)。测试提交与计数器已回滚,见清理记录。
### F6 —— 提交接口缺限流【Important】→ **ADDRESSED参数与旧后端逐字对齐独立验证**
见下方「重点判断 4」。独立绕过 HTTP 层直接调用 `consumeToken()` 12 次(避免污染 submission 计数):
```
#1~#10 allowed:true
#11 allowed:false, wait≈33.32s
#12 allowed:false, wait≈33.32s
```
`wait = (1 - 0) / 0.03 ≈ 33.33`,与旧后端 `fill_rate=0.03` 精确吻合,`default_capacity=10` 也吻合(第 11 次才被挡)。
---
## 四个重点判断
### 1. F2 是否真的做成了「默认关闭的开关」?
**是。** `apps/api/src/routes/helpers.ts``sampleUser()` 是唯一入口,`options.includeRealName === true` 才下发真名,默认 `false`。核对了全仓 14 个下发点(比 findings 文档的 13 处多一个,多出的是 `problem.ts:333`,原先就写死 `null`,现在统一走 `sampleUser` 入口,不影响结论):
| 下发点 | 状态 |
|---|---|
| rankings/users、announcements(x2)、messages、tutorials/:id、problems 列表(x2)、problem-sets(x2)、problemset progress、contest creator(x2)、contest problems(x2) | 全部 `sampleUser(user, realName)`,默认关 |
| `contest.ts:209`(比赛榜单) | 唯一 `{ includeRealName: admin }`,对齐旧后端 `contest/serializers.py:84` |
`grep -rn "realName:" apps/api/src/routes/*.ts` 复查无遗漏(本次独立复跑,非照抄报告)。**不是逐处删字段**——`sampleUser()` 是统一的序列化函数,下次新增端点如果照抄现有写法(调用 `sampleUser`)默认就是关的,不会重犯。唯一的隐患是「有人手写字面量绕过 `sampleUser`diff 里已经全部清干净,长期靠代码评审维持(无法用类型系统强制,值得记一条范围外观察)。
### 2. `isRegularUser` 是否真的只有一处调用?删除后是否有其它同类空值陷阱?
**只有一处,属实。** `grep -rn "isRegularUser" --include=*.ts .`(排除 node_modules覆盖整个仓库而非只有 apps/、packages/)只命中 `helpers.ts` 里的警示注释,无任何遗留调用点。
**搜了其它同类模式**(对 `null` 用户取 `adminType` 做权限判断),命中 `apps/api/src/routes/flowchart.ts:98`
```ts
if (c.req.query("myself") === "1" || (!username && user.adminType === "Regular User")) ...
```
这处**不是**同类陷阱:该路由挂在 `flowchartRoutes.get("/flowcharts", requireAuth, ...)``user``c.get("user")!` 取得,`requireAuth` 保证非空匿名到不了这行。这是「Regular User 专属限制」的合法写法(限制普通用户只看自己的,不限制教师/管理员),语义与 F3 的场景不同——F3 的路由是 `optionalAuth`,匿名 `user` 可能为 `null`。**未在 diff 内,非本次改动引入,仅作范围外观察记录**,不计入 findings。
其余 `adminType` 使用点(`classroom.ts``account.ts``contest.ts``inArray`/`sql` 过滤,`services/contest.ts``isContestAdmin`)均为「构造 SQL 过滤条件」或「非 null 保证下的角色判断」,没有第二个「匿名反而权限更大」的实例。
### 3. F5b 的 `.env` 加载器是否安全?
**安全,三点分别独立验证:**
- **真实 env 优先于仓库根 `.env`**`JUDGE_SERVER_TOKEN=from-shell-env bun -e '...'` 得到 `from-shell-env`,不是 `.env` 里的值。
- **cwd 下的 `.env`Bun 自动加载)优先于仓库根 `.env`**`bun --env-file=.env.test-cwd` 模拟 cwd 优先加载后,`loadRepoRootEnv()` 因为 `process.env[key] !== undefined` 而跳过,结果仍是 cwd 的值。
- **解析不会被畸形行搞崩**:构造了空值(`=novalue`)、无等号行、前导空格键、单/双引号、值里带等号、`export FOO=bar` 语法、行内 `#` 注释、CRLF 结尾等混合样本喂给等价解析逻辑,全部正常跳过或按字面处理,无异常抛出。(`export FOO=bar` 会被解析成键名 `"export FOO"`,即该变量实际上不会被正确加载——是一个小的解析局限,不是崩溃,本仓库的 `.env`/`.env.example` 都不用这种写法,记为范围外观察。)
- **根目录无 `.env` 时静默跳过**`readFileSync``ENOENT``catch {}` 吞掉,不影响启动——这是生产场景(真实环境变量注入)的必经路径,逻辑上有覆盖(未在容器里额外验证,风险低,纯 try/catch 结构)。
- **端到端**:仓库根 `.env` 当前有真实 `JUDGE_SERVER_TOKEN`,直接提交一条真实代码(问题 1002/Python3判题机在数秒内返回非 PENDING/JUDGING 结果,证明 token 握手成功、判题闭环工作,不是只停留在配置读取层面。
### 4. 限流参数是否真的对齐旧后端?
**是,非拍脑袋。** 对照 `OnlineJudge/options/options.py:120`
```python
throttling = {"ip": {"capacity": 100, "fill_rate": 0.1, "default_capacity": 50},
"user": {"capacity": 20, "fill_rate": 0.03, "default_capacity": 10}}
```
`apps/api/src/services/throttling.ts``throttlingDefaults` 逐字段相同。
**挂点位置**对照 `OnlineJudge/submission/views/oj.py:68``SubmissionAPI.post``throttling()``check_contest_permission`(比赛权限校验)之后、取 `Problem` 之前调用——`apps/api/src/routes/submission.ts` 的挂点(比赛权限校验后、`db.select(schema.problem)` 前)位置一致。
**独立数值验证**:绕开 HTTP 直接调用 `consumeToken()` 12 次,第 11 次起被拒,`wait≈33.32s`,与 `(1 token 缺口) / (fill_rate=0.03) ≈ 33.33s` 吻合,`default_capacity=10` 与观察到的"第 11 次才拒绝"一致。参数和算法都对得上,不是抄了个数字但算法跑偏。
Lua 脚本把旧实现「非线程安全」的读改写做成了原子操作,这是双方都认可的合理增强,不是风险点。)
---
## 修复 diff 内新引入的破坏
**无。**
- `bunx tsc --noEmit` 独立重跑0 错误。
- 独立冒烟测试 `/api/contests``/api/problems``/api/problem-sets``/api/rankings/users``/api/announcements``/api/submissions``/api/problems/1002` 等端点,均 200`sampleUser()` 各调用点(含 `row ?? { id, username: "" }` 的 null 分支、`{ id: row.creatorId, username: row.creatorUsername }` 的裸对象分支)未见运行时异常。
- `apps/web` 零改动diff stat 确认F1/F4 对前端的潜在影响(`data:null` 分支、`info.data` 表格不渲染)均有既存代码兜底或本就是回归到旧后端行为,读码确认不炸。
- F4b 的 `contestId` 收口用真实比赛提交实测通过,未见回归。
## 范围外观察(仅记录,不阻塞)
1. `apps/api/src/routes/flowchart.ts:98` 有一处外观相似的 `user.adminType === "Regular User"` 判断,但路由挂 `requireAuth`,不构成 F3 类陷阱。建议后续如果这条路由改成 `optionalAuth`,需要一并检查。
2. `.env` 解析器不支持 `export KEY=value` 语法(会把 `export KEY` 当整个键名),本仓库当前 `.env`/`.env.example` 不用这种写法,暂无影响。
3. F2 的「默认关闭」防线目前只靠约定(大家都调用 `sampleUser()`),没有类型系统强制。长期看这是 admin 侧 45 个端点铺开前值得补一道 lint/测试的地方,但不属于本次 findings。
4. 限流的 429 是新引入的 HTTP 语义(旧后端全走 200+error 信封),前端目前没有针对 429 的专门处理(报告里已自述,非新发现)。
## 结论
**All findings addressed: Yes**
F1 / F2 / F3 / F4 / F4b / F5 / F5b / F6 —— 8 项全部 ADDRESSED均有独立实跑或读码验证支持diff 范围内未发现新引入的破坏。