diff --git a/docs/specs/phase3-fix-list.md b/docs/specs/phase3-fix-list.md new file mode 100644 index 0000000..d9ff238 --- /dev/null +++ b/docs/specs/phase3-fix-list.md @@ -0,0 +1,166 @@ +# 阶段 3 修复清单(两份评审合并) + +日期:2026-08-07 +来源:`phase3-review-authz.md`(权限边界)+ `phase3-review-leakage.md`(数据泄露) +受审代码:commit `8c00cdc`,oj 侧 65 个端点 + +## 合并说明 + +两份评审独立进行、互不知情,却各自命中了同两条问题(`/profiles/:username` 匿名可读、 +`realName` 无条件下发)。**独立复现提高了可信度**,此处合并为一条。 + +两份评审对同一问题的严重度判定不一致时,**取更严的一方**。理由:使用者是中职学生(未成年人), +姓名、邮箱、班级属于个人信息,泄露的后果不由技术标准衡量。 + +控制方(本文档作者)已对 F1、F2、F3 独立实跑复现,证据附在各条下。 + +--- + +## F1 —— 匿名可读任意用户完整档案 【Critical】 + +- **位置**:`apps/api/src/routes/account.ts:101`(路由)、`apps/api/src/services/profile.ts:19`(字段) +- **两份评审均命中**:authz C1 / leakage I-1 +- **问题**:`GET /profiles/:username` 只挂 `optionalAuth`,handler 内无登录判断,匿名可读 + `email`、`adminType`、`problemPermission`、`isDisabled`、`className`、`lastLogin` +- **旧后端行为**:`OnlineJudge/account/views/oj.py` 的 `UserProfileAPI.get` 第一行即 + `if not user.is_authenticated: return self.success()` —— 匿名直接返回空 +- **放大效应**:用户名可经 `GET /rankings/users` 公开枚举,因此可无 cookie 批量收集 + 全校学生的邮箱与最后登录时间 + +**控制方实跑证据**: +``` +匿名 GET /api/profiles/e2etest -> 200 +含 email: true | 含 adminType: true | 含 realName: true +{"user":{"username":"e2etest","email":"e2e@local.test","adminType":"Regular User", + "problemPermission":"None","lastLogin":"...","isDisabled":false,"className":"2301"}} +``` + +**修法**:handler 开头判断未登录即返回空,对齐旧行为。 + +--- + +## F2 —— 学生真名无条件下发 【Critical】 + +- **位置**:13 个下发点,其中 8 个匿名可达;`apps/api/src/routes/account.ts:168` 为典型 +- **两份评审均命中**:authz C2 / leakage I-2 +- **问题**:旧后端将「是否返回真名」设计为 DRF `UsernameSerializer(need_real_name=False)` 的 + **默认关闭**开关,全仓 11 处调用中仅比赛榜单一处显式打开。新后端未搬运这一层,真名随 + 用户对象无条件下发 +- **唯一做对的地方**:`apps/api/src/routes/contest.ts:209` + +**控制方实跑证据**: +``` +匿名 GET /api/rankings/users -> 200 +[{"user":{"id":4,"username":"e2etest","realName":"..."},...}, + {"user":{"id":2,"username":"student","realName":"Phase 2 Student"},...}] +``` +(`"Phase 2 Student"` 为本机既有数据,非评审探针造出) + +**修法**:在用户对象的序列化层加一个默认关闭的 `includeRealName` 开关,逐个下发点显式开启; +默认不给。**不要逐处删字段** —— 那样下次新增端点会重犯。 + +--- + +## F3 —— 匿名绕过提交可见性守卫,权限大于登录用户 【Critical】 + +- **位置**:`apps/api/src/routes/submission.ts:211`、`apps/api/src/routes/helpers.ts:27` +- **来源**:authz C3(leakage 未覆盖) +- **问题**: + ```ts + export function isRegularUser(user: AuthUser | null | undefined) { + return user?.adminType === "Regular User" // isRegularUser(null) === false + } + // submission.ts:211 + if (!(await getBooleanOption("submission_list_show_all", true)) && isRegularUser(user)) { + // 限制为只看自己的提交 + } + ``` + 匿名用户的 `isRegularUser(null)` 为 `false`,守卫整体短路,限制不生效 +- **潜伏性**:`submission_list_show_all` 默认为 `true`,该分支平时不执行,**开关一旦关闭立即暴露**。 + 而关闭这个开关的典型场景正是考试 + +**控制方实跑证据**(临时把开关置 false,测后已还原): +``` +匿名 total = 10 ← 全部可见 +登录学生 total = 0 ← 被正确限制 +``` + +**修法**:守卫应为「非管理员即受限」,而非「是普通用户才受限」。匿名必须落入受限分支。 +建议改用 `!isAdminRole(user)`(该函数对 null 返回 `false`,语义正确)。 + +--- + +## F4 —— 自己的提交详情返回判题内部信息与 IP 【Important】 + +- **位置**:`apps/api/src/routes/submission.ts:187` +- **来源**:leakage I-3 +- **问题**:返回 `info`(含每个测试点的 `test_case` 编号与 `output_md5`)与 `ip` +- **旧后端行为**:这两个字段以 `is_admin_role()` 把关,而非「是不是自己的提交」 +- **风险**:测试点编号与输出 md5 可用于反推测试数据规模与部分答案特征 + +**修法**:`info` 与 `ip` 改为仅管理员可见,对齐旧后端。 + +--- + +## F5 —— 判题机 token 默认值硬编码进仓库 【Important】 + +- **位置**:`docker/compose.dev.yml` 的 `${OJ2_JUDGE_TOKEN:-oj2-dev-token}` 及后端读取处 +- **来源**:authz Important +- **问题**:token 校验本身实现正确(用了 `timingSafeEqual`),但缺省值 `"oj2-dev-token"` + 写死在仓库里。旧后端在 env 缺失时用 `rand_str()` fail-safe,宁可不可用也不用弱默认值 +- **当前影响有限**:`judge/run.ts` 用 env 里的 URL 而非数据库中的 `service_url` + +**修法**:去掉默认值,env 缺失时启动失败或生成随机值,不要静默使用弱默认。 + +--- + +## F6 —— 提交接口缺少限流 【Important】 + +- **位置**:`apps/api/src/routes/submission.ts` 的 `POST /submissions` +- **来源**:authz Important +- **问题**:旧后端有 TokenBucket 限流,新后端未搬运 +- **风险**:判题沙箱是有限资源,学生(或脚本)可连续提交打满队列 + +**修法**:按旧后端的限流参数重建。 + +--- + +## Minor(共 7 条) + +详见两份原始报告,未在此展开。合并去重后主要为:命名不一致、错误信息过于具体、 +若干处可维护性问题。**不阻塞,但建议在 admin 侧开工前一并清掉**,避免同样的模式被复制 45 次。 + +--- + +## 评审确认没有问题的部分 + +两份评审各自独立核实、结论一致的部分: + +- **无任何敏感字段泄露**:`raw_password`、`password`、`auth_token`、`open_api_appkey`、 + `session_keys` 在 111 个请求的响应中零命中(含明文密码值的全文 grep) +- **无泄题**:`answers`、`ast_rules`、`test_case_id`、`test_case_score` 均未泄露, + 已在 `answers` 含完整 C/Python 参考解的题目 1002 上专项验证 +- **模板隐藏区正确剥离** +- **`contest.password` 未泄露** +- **11 处 `db.select()` 选全列的写法全部后接显式字段映射 + Zod strip,无一泄露** +- **比赛权限是重建得最好的一块**:`sig#timestamp` 密码、未开始拦截、`ProblemSafeSerializer` + 脱敏字段集逐行对齐,五个端点实跑全部正确拦截 +- 覆盖面:111 个请求,65 个 oj 路由中 61 个取得 2xx + +--- + +## 附带需处理:本地样本数据受损 + +泄露评审的种子脚本覆盖了**题目 1001(`problem.id=2`)**的 `ast_rules`、`answers`、 +`test_case_score`、`template`,原值已丢失(评审用兄弟题目的值做了近似填充,当前 +`answers` 为 `[]`)。导入用的 `/tmp/problems.csv` 也已不存在。 + +影响:本地样本数据的价值在于真实(真实富文本、LaTeX、中文最能暴露序列化问题), +1001 现已失真。 + +处理:需要时从生产重新导出该行: +```bash +docker exec oj-postgres psql -U onlinejudge -d onlinejudge -c \ + "\copy (SELECT * FROM problem WHERE _id='1001') TO STDOUT WITH CSV HEADER" > p1001.csv +``` +不紧急 —— 其余 19 道题未受影响。 diff --git a/docs/specs/phase3-review-authz.md b/docs/specs/phase3-review-authz.md new file mode 100644 index 0000000..056aed8 --- /dev/null +++ b/docs/specs/phase3-review-authz.md @@ -0,0 +1,338 @@ +# 阶段 3 权限与鉴权边界评审 + +日期:2026-08-07 +受审对象:`apps/api`(Hono + Bun),oj 侧 65 条端点 +参照基准:`../OnlineJudge`(Django + DRF,只读) +工作清单:`docs/specs/phase3-coverage.md` 的「oj 侧逐条对照」表 +验证方式:逐条读源码 + 对本机 `http://localhost:3000` 实跑(测试账号 `e2etest`,普通学生) + +--- + +## 1. 结论摘要 + +**3 条 Critical,2 条 Important,4 条 Minor。** + +整体上权限骨架是**忠实重建**的:比赛的 `check_contest_permission`(含密码保护、未开始拦截、contest admin 豁免、ProblemSafeSerializer 的字段脱敏)、提交的 `check_user_permission`、流程图的所有者判断、角色判定函数(`is_admin_role` / `is_teacher_or_above` / `is_contest_admin`)都与旧后端逐行等价,实跑验证也全部对上。**我事先怀疑的四处里有三处是误判**——`GET /contests/:id`、`GET /classes/:className/usernames`、`GET /users/:id/metrics` 在旧后端本来就是无鉴权的,新后端没有放宽;判题机心跳也确实做了 token 校验,只是校验用的密钥有问题。 + +真正的问题集中在**"序列化层的隐式权限"**:旧后端把权限判断藏在 DRF 序列化器的默认参数里(`UsernameSerializer(need_real_name=False)`、`UserProfileAPI` 开头的 `is_authenticated` 短路),新后端重写时只搬了装饰器、没搬序列化器里的这层,于是**真实姓名和邮箱被无差别下发给未登录访客**。学生是中职生(未成年人),真名 + 用户名 + 班级可以直接拼出花名册,这是本次最严重的问题。 + +另有一处逻辑翻转:`submission_list_show_all=false` 时,**未登录用户能拿到全站提交列表,而登录的学生拿到空列表**——匿名比登录更有权限。 + +--- + +## 2. 逐条对照表(只列有差异的行) + +未列出的条目 = 新旧权限要求一致,见第 4 节。 + +| # | 旧端点 | 旧权限要求 | 新路由 | 新权限实现 | 判定 | +|---|---|---|---|---|---| +| 1 | `account/profile`(GET) | 视图首行 `if not user.is_authenticated: return success()` —— 等价 `@login_required` | `GET /profiles/:username` | `optionalAuth`,无任何 user 判断 | **更松(Critical)** | +| 2 | `account/user_rank` | 无装饰器,但 `RankInfoSerializer → UsernameSerializer(need_real_name=False)` 恒返回 `real_name: null` | `GET /rankings/users` | 无中间件,`realName: profile.realName` 无条件下发 | **更松(Critical)** | +| 3 | `submission/submissions` | 无装饰器;`not show_all and user.is_regular_user()` —— 匿名走到这行会 `AttributeError`(Django `AnonymousUser` 无该方法),必定拿不到数据 | `GET /submissions` | `optionalAuth`;`isRegularUser(null) === false` → 守卫不生效,直接返回全量 | **更松(Critical)** | +| 4 | `announcement/announcement` | `AnnouncementSerializer.created_by = UsernameSerializer()` → `real_name: null` | `GET /announcements`、`GET /announcements/:id` | `createdBy.realName` 无条件下发(content.ts:43、64) | **更松** | +| 5 | `contest/contests`、`contest/contest` | `ContestSerializer.created_by = UsernameSerializer()` → `null` | `GET /contests`、`GET /contests/:id` | `creator()` 返回 realName(contest.ts:31-34 → :47) | **更松** | +| 6 | `problem/problem`(列表与详情) | `ProblemListSerializer.created_by = UsernameSerializer()` → `null` | `GET /problems`、`GET /problems/:displayId/similar` | `listItem()` 下发 realName(problem.ts:86) | **更松** | +| 7 | `problem/contest/problem` | 同上 | `GET /contests/:id/problems`、`.../:displayId` | contest.ts:131、:177 下发 realName | **更松** | +| 8 | `problemset/problemset`、`/`、`//problems` | `UsernameSerializer()` → `null` | `GET /problem-sets*` | problemset.ts:62、:169 下发 realName | **更松** | +| 9 | `problemset//users_progress` | `@teacher_admin_required` + `ProblemSetProgressSerializer.user = UsernameSerializer()` → **即使教师也拿不到 real_name** | `GET /problem-sets/:id/user-progress` | `requireAuth` + `isTeacherOrAbove`(角色判断一致),但 problemset.ts:398 下发 realName | 角色一致,字段**更松** | +| 10 | `message/message`(GET) | `@login_required` + `sender = UsernameSerializer()` → `null` | `GET /messages` | `requireAuth`(一致),content.ts:85 下发 `sender.realName` | 角色一致,字段**更松** | +| 11 | `tutorial/tutorial` | 无装饰器;`created_by = UserSerializer()`(**含 email**,无 real_name) | `GET /tutorials/:id` | 无中间件;`createdBy.realName` 下发,不含 email | realName **更松** / email **更严** | +| 12 | `conf/judge_server_heartbeat/` | 无装饰器,校验 `sha256(SysOptions.judge_server_token)`;env 缺失时回落到 `rand_str()`(随机、fail-safe) | `POST /judge-server/heartbeat` | 校验 `sha256(config.judgeServerToken)`;env 缺失时回落到硬编码常量 `"oj2-dev-token"`(fail-open) | **更松(Important)** | +| 13 | `submission/submission`(POST) | `@login_required` + `TokenBucket` 用户级限流(`SysOptions.throttling`) | `POST /submissions` | `requireAuth`,**无限流** | **更松(Important)** | +| 14 | `conf/class_usernames` | 无装饰器,`classroom` 参数无格式校验 | `GET /classes/:className/usernames` | 无中间件,但加了 `^\d{3,4}$` 校验 | **更严** | +| 15 | 全部需登录端点 | 装饰器内 `if request.user.is_disabled: return error` | 所有路由 | `getUserByToken` 发现 `isDisabled` 直接删 session 并返回 null | **更严**(禁用即时踢线) | + +--- + +## 3. Findings + +### Critical + +--- + +#### C1 — `GET /api/profiles/:username` 未登录即可读取任意用户完整档案(含邮箱) + +**位置**:`apps/api/src/routes/account.ts:101-108`(`optionalAuth`) +**服务层**:`apps/api/src/services/profile.ts:6-41` +**旧后端**:`OnlineJudge/account/views/oj.py:38-41` + +```python +async def get(self, request, **kwargs): + user = request.user + if not user.is_authenticated: + return self.success() # ← 匿名直接返回空,等价 @login_required +``` + +新后端只用了 `optionalAuth`,handler 内部**没有任何** `c.get("user")` 判断,`getUserProfileById` 的第二参数只控制 `realName` 是否下发,其余字段(含 `email`)无条件返回。 + +**实跑证据**(无 cookie): + +``` +$ curl -s http://localhost:3000/api/profiles/e2etest +{"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:25:35.98+00", +"openApi":false,"isDisabled":false,"className":null}, +"realName":null,"acmProblemsStatus":{"problems":{"5":{"_id":"1004","status":0}}}, +"avatar":"/public/avatar/default.png","blog":null,"mood":"probe", +"github":null,"school":null,"major":null,"language":null, +"acceptedNumber":1,"submissionNumber":2}} +``` + +**复现**:`curl http://localhost:3000/api/profiles/<任意用户名>`,不带 Cookie。 + +**影响**:泄露邮箱、账号角色(`adminType` 可用于定位管理员账号做定向爆破)、最后登录时间、班级、全部做题记录。配合同样无鉴权的 `GET /classes/:className/usernames`(可枚举整班用户名,旧后端同样开放),可无凭据批量拉取全校师生的邮箱与角色。 + +--- + +#### C2 — 真实姓名(`realName`)被无条件序列化,未登录即可读取学生真名 + +**最严重实例**:`apps/api/src/routes/account.ts:168`(`GET /rankings/users`,无任何中间件) + +```ts +user: { id: user.id, username: user.username, realName: profile.realName }, +``` + +**旧后端**:`OnlineJudge/account/serializers.py:145-150` + `OnlineJudge/utils/api/_serializers.py:9-14` + +```python +class RankInfoSerializer(serializers.ModelSerializer): + user = UsernameSerializer() # ← need_real_name 默认 False + +class UsernameSerializer(serializers.Serializer): + def get_real_name(self, obj): + return obj.userprofile.real_name if self.need_real_name else None +``` + +旧后端把"是否下发真名"做成了序列化器的**默认关闭开关**,全仓库 11 处 `UsernameSerializer(...)` 调用里只有 1 处显式打开(`contest/serializers.py:84`,且条件是 `is_contest_admin`)。新后端没有对应机制,凡是 join 到 `user_profile` 的地方就直接把 `realName` 塞进响应。 + +**实跑证据**(先给 `e2etest` 写入真名,再无 cookie 请求,最后已还原为 NULL): + +``` +$ curl -s "http://localhost:3000/api/rankings/users?limit=5" +{"data":{"results":[ + {"id":2,"user":{"id":4,"username":"e2etest","realName":"张三(审计测试)"}, + "acceptedNumber":1,"submissionNumber":2,"mood":"probe"}, + {"id":1,"user":{"id":2,"username":"student","realName":"Phase 2 Student"}, + "acceptedNumber":1,"submissionNumber":5,"mood":null}, ...]}} +``` + +`student` 这条是**本机既有数据**,没有经过我任何修改,其 `real_name = "Phase 2 Student"` 直接被匿名请求读到。 + +**同一根因的全部下发点**(旧后端对应字段一律为 `null`): + +| 文件:行 | 路由 | 是否匿名可达 | +|---|---|---| +| `routes/account.ts:168` | `GET /rankings/users` | **是**(学生真名,危害最大) | +| `routes/content.ts:43` | `GET /announcements` | 是 | +| `routes/content.ts:64` | `GET /announcements/:id` | 是 | +| `routes/content.ts:196` | `GET /tutorials/:id` | 是 | +| `routes/contest.ts:34`(`creator()`,经 :47) | `GET /contests`、`GET /contests/:id` | 是 | +| `routes/problem.ts:86`(`listItem()`) | `GET /problems`、`GET /problems/:displayId/similar` | 是 | +| `routes/problemset.ts:62`(`problemSetCreator()`) | `GET /problem-sets`、`GET /problem-sets/:id` | 是 | +| `routes/problemset.ts:169` | `GET /problem-sets/:id/problems` | 是 | +| `routes/contest.ts:131` | `GET /contests/:id/problems` | 需登录 + 过密码 | +| `routes/contest.ts:177` | `GET /contests/:id/problems/:displayId` | 需登录 + 过密码 | +| `routes/content.ts:85` | `GET /messages`(`sender.realName`) | 需登录 | +| `routes/problemset.ts:398` | `GET /problem-sets/:id/user-progress` | 需教师 | + +注:`routes/problem.ts:333`(`GET /problems/:displayId`)和 `routes/contest.ts:209`(比赛榜单,`admin ? realName : null`)是**正确**的两处,说明这套逻辑并非无人知晓,只是没有被统一执行。 + +**建议**:在 `sampleUserSchema` 层面把 `realName` 变成必须显式打开的字段(例如收敛成一个 `serializeUserRef(user, { realName: boolean })` 帮助函数),而不是在 13 个调用点分别记得置 null。 + +--- + +#### C3 — `submission_list_show_all=false` 时,未登录用户能读全站提交列表,登录学生反而读不到 + +**位置**:`apps/api/src/routes/submission.ts:211` + +```ts +if (!(await getBooleanOption("submission_list_show_all", true)) && isRegularUser(user)) { + return success(c, submissionListSchema.parse({ results: [], total: 0 })) +} +``` + +`isRegularUser` 定义于 `routes/helpers.ts:27-29`:`user?.adminType === "Regular User"`。**匿名用户 `user === null` → 返回 `false` → 守卫短路,全量下发。** + +**旧后端**:`OnlineJudge/submission/views/oj.py:149-151` + +```python +show_all = await SysOptions.aget("submission_list_show_all") +if not show_all and request.user.is_regular_user(): + return self.success({"results": [], "total": 0}) +``` + +Django 的 `AnonymousUser` 没有 `is_regular_user` 方法(`account/models.py:53` 定义在自定义 `User` 上),匿名请求走到这行会抛 `AttributeError` → 500。旧后端在这个配置下**绝不可能**把列表交出去。 + +**实跑证据**(临时插入 `options_sysoptions` 行,测完已 DELETE 还原): + +``` +site.submissionListShowAll = false +ANON /submissions?limit=2 -> total=7 n=2 ← 未登录,拿到全部 7 条 +REGULAR logged-in /submissions?limit=2 -> total=0 n=0 ← 登录学生,拿到 0 条 +``` + +匿名响应含每条提交的 `username`、题号、语言、结果、耗时。 + +**复现**:把 `options_sysoptions` 中 `submission_list_show_all` 置为 `false`,然后不带 Cookie 请求 `GET /api/submissions?limit=10`。 + +**影响**:该开关的唯一用途就是"考试期间不让学生互相看提交"。现在学生只要开一个隐私窗口(或直接删 Cookie)就能绕过,开关等于失效。 + +--- + +### Important + +--- + +#### I1 — 判题机共享密钥的默认值硬编码在仓库里(fail-open) + +**位置**:`apps/api/src/config.ts:8` + +```ts +judgeServerToken: process.env.JUDGE_SERVER_TOKEN ?? "oj2-dev-token", +``` + +`.env.example:4` 同样写着 `JUDGE_SERVER_TOKEN=oj2-dev-token`。 + +**旧后端**:`OnlineJudge/options/options.py:92-94` + +```python +def default_token(): + token = os.environ.get("JUDGE_SERVER_TOKEN") + return token if token else rand_str() # ← 没配就随机,fail-safe +``` + +生产 compose(`OnlineJudge/docker-compose.yml:44,65`)显式注入 `_Mam^^1rvC86Qko2d0`。也就是说旧后端**漏配环境变量的后果是判题机连不上(立刻暴露)**,新后端**漏配的后果是任何人都能通过心跳校验(静默)**。 + +**实跑证据**: + +``` +no-token -> 403 {"error":{"code":"invalid-judge-token",...}} ← 校验存在,符合旧行为 +default-dev-token -> 200 {"error":null,"data":null} ← 用仓库里的常量即通过 +``` + +(本机确实跑在默认 token 下。测试插入的 `hostname='pwned-by-audit'` 行已删除。) + +**当前实际影响有限**:`judge/run.ts:69` 派发判题用的是 `config.judgeServerUrl`(环境变量),**不读** DB 里的 `service_url`,所以伪造心跳无法把判题流量劫走,只能污染 `judge_server` 表 / 管理端仪表盘。但这是运气好,不是设计使然——`service_url` 字段被写进了库(judge-server.ts:63),一旦将来 admin 侧或调度器改成读它,同一个洞就升级为 Critical。 + +**建议**:`JUDGE_SERVER_TOKEN` 缺失时启动即 fail(或生成随机值并打日志),不要留可猜的默认值。 + +--- + +#### I2 — `POST /submissions` 丢失了提交限流 + +**位置**:`apps/api/src/routes/submission.ts:52`(只有 `requireAuth`) +**旧后端**:`OnlineJudge/submission/views/oj.py:35-42`、`:68-70` + +```python +def throttling(self, request): + user_bucket = TokenBucket(key=str(request.user.id), redis_conn=cache, + **SysOptions.throttling["user"]) + can_consume, wait = user_bucket.consume() + if not can_consume: + return "Please wait %d seconds" % (int(wait)) +``` + +旧配置 `{"capacity": 20, "fill_rate": 0.03}` ≈ 每用户约 2 提交/分钟。新后端没有任何等价物,任一登录学生可以无限速向 `judgeQueue` 灌任务。这不是读权限问题,但它是旧后端里唯一挡住"一个学生打爆判题沙箱"的机制,重写时整体丢失了,归入本报告以免遗漏。 + +--- + +### Minor + +**M1 — `isAdminRole` 从白名单退化为黑名单** +`routes/helpers.ts:31-33`:`Boolean(user && user.adminType !== "Regular User")`。 +旧:`account/models.py:65-70` 显式列举 `[STUDENT_ADMIN, TEACHER_ADMIN, SUPER_ADMIN]`。 +当前四种角色下两者等价,但将来新增任何角色(如"助教""家长")都会**默认获得 admin 权限**,包括 `canViewSubmission` 里的"看所有人代码"。建议改回白名单。 + +**M2 — 比赛权限判断没有中间件兜底** +`canAccessContest` 在 `contest.ts:116/144/193`、`submission.ts:63/253` 共 5 处手工调用。旧后端用 `@check_contest_permission` 装饰器,漏挂会很显眼;新后端漏调一次就是静默放行,且 `GET /contests/:id/problems` 这类路由挂的是 `optionalAuth`(本身不拦人),从中间件列表上完全看不出它受保护。目前 5 处都调对了,属可维护性风险。 + +**M3 — `blog` / `github` 从 URLField 降级为自由字符串** +`packages/contract/src/account.ts:15,17` 用 `z.string().max(256)`;旧 `account/serializers.py:125,127` 是 `serializers.URLField`。现在 `PUT /me/profile` 可以写入 `javascript:alert(1)` 之类的值,是否可利用取决于前端如何渲染这两个字段。 + +**M4 — `GET /dev/problems` 仍在线且无鉴权** +`routes/problem.ts:231`。内容本身是公开题目摘要,无实际泄露,但它是阶段 1 的临时端点,`phase3-coverage.md` 第 120 行已标注应删除,至今还挂在生产路由树上。 + +**(文档纠错,非安全问题)** `phase3-coverage.md:96` 把 `PUT submissions/:id` 描述为"判题结果写回"。实际实现(`submission.ts:301-316`)是**提交分享开关**,对应旧 `SubmissionAPI.put` + `ShareSubmissionSerializer`。判题结果写回走的是内部 worker,不经 HTTP。这条描述会让人误以为存在一个需要判题机凭据的写入端点,建议改正。 + +--- + +## 4. 核实过、确认没有问题的项 + +以下是逐条读过源码(多数还实跑过)、确认**新后端权限要求与旧后端等价或更严**的部分。 + +**四个预先怀疑点里的三个是误判:** + +- `GET /contests/:id`(contest.ts:78)——旧 `ContestAPI.get`(`contest/views/oj.py:38-51`)本来就**没有任何装饰器**,公开可读。新后端行为一致,且 `serializeContest` 不下发 `password`(旧 `ContestSerializer` 亦 `exclude`)。**真正受保护的比赛内容(题目、榜单、提交)全部走 `canAccessContest`,实跑验证如下:** + + ``` + ANON /contests/5 200 ← 与旧后端一致 + ANON /contests/5/problems 401 login-required + ANON /contests/5/rank 401 login-required + ANON /contests/5/submissions 401 login-required + ANON /contests/5/access 401 login-required + AUTHnopw /contests/5/problems 403 wrong-password ← 登录但没过密码,正确拦截 + AUTHnopw /contests/5/rank 403 wrong-password + ``` + (测试用的密码保护比赛 id=5 已删除。) + +- `GET /classes/:className/usernames`(site.ts:36)——旧 `ClassUsernamesAPI`(`conf/views.py:237-243`)无装饰器,公开。新后端一致,且**多加了** `^\d{3,4}$` 格式校验。属旧后端遗留的开放面,不是本次重写引入的(但确实是 C1 的放大器,值得单独排期收口)。 + +- `GET /users/:id/metrics`(account.ts:140)——旧 `Metrics`(`account/views/oj.py:66-83`)无装饰器,按 `?userid=` 任取,也不限制只查自己。新后端一致。同上,属遗留开放面。 + +- `POST /judge-server/heartbeat`——token 校验**存在**且用了 `timingSafeEqual`,比旧后端的裸字符串比较更好;问题只在默认值(见 I1)。 + +**比赛权限(最复杂的一块,逐条对齐):** + +- `canAccessContest`(`services/contest.ts:45-63`)与 `check_contest_permission._check_access`(`account/decorators.py:122-136`)逐行等价:未登录 → login-required;contest admin 豁免;密码保护校验 session 内存的密码;`check_type != "details"` 时拦截未开始的比赛。 +- `checkContestPassword`(`services/contest.ts:28-37`)完整复刻了 `sig#timestamp` 形式(`decorators.py:87-110`),含 sha256 前 8 位与过期判断。 +- `contestDetailsAllowed`(`services/contest.ts:24-26`)= `Contest.problem_details_permission`(`contest/models.py:42-43`)。 +- 比赛未结束时对非管理员脱敏的字段集完全一致:`difficulty`、`submissionNumber`、`acceptedNumber`、`statisticInfo` 被置空 —— 正是旧 `ProblemSafeSerializer`(`problem/serializers.py:211-228`)exclude 的那几个(`answers` 新后端从不下发)。 +- 比赛榜单 `realName` 正确地由 `admin ? realName : null` 控制(contest.ts:209),对齐 `ACMContestRankSerializer(is_contest_admin=...)`。 +- `POST /submissions` 的比赛分支完整保留了"比赛已结束禁止提交"和 `allowedIpRanges` 白名单(submission.ts:65-68 vs `submission/views/oj.py:47-53`);`ipAllowed`(services/contest.ts:71-85)的 CIDR 计算正确,空列表放行、无 IP 拒绝。 +- `GET/POST /contests/:id/access` 均要求登录且比赛必须设了密码,与旧 `ContestAccessAPI` / `ContestPasswordVerifyAPI`(均 `@login_required`)一致。 + +**提交与流程图的所有者判断:** + +- `canViewSubmission`(submission.ts:166-178)与 `Submission.check_user_permission`(`submission/models.py:47-56`)等价,且新增了 `if (!user) return false` —— 更严。 +- `check_share=False` 语义(用于分享开关和 `canUnshare`)正确传递。 +- `GET /submissions/:id` 用 `requireAuth`,无权限时返回 404 而非 403,不泄露存在性 —— 比旧的 "No permission for this submission" 更严。 +- `PUT /submissions/:id`(分享开关)保留了"比赛进行中不得分享"。 +- 流程图 `canView`(flowchart.ts:24-26)= `FlowchartSubmission.check_user_permission`(`flowchart/models.py:59-63`);列表的 `myself` / `username` / 普通用户默认只看自己 三段逻辑与 `flowchart/views/oj.py:78-83` 一致;`current` / `history` 强制 `userId = 自己`。 + +**角色与身份:** + +- `isRegularUser` / `isAdminRole` / `isTeacherOrAbove` / `isSuperAdmin`(helpers.ts:27-41)与 `account/models.py:53-73` 语义一致(`isAdminRole` 的黑名单写法见 M1)。 +- `isContestAdmin`(services/contest.ts:20-22)= `User.is_contest_admin`(`account/models.py:78-79`)。 +- 禁用账号:`getUserByToken`(auth/session.ts:105-108)发现 `isDisabled` 立即删除 session 并返回 null,比旧后端"每个装饰器里各判一次"更彻底。 +- Session token 用 `randomBytes(32)` + Redis,Cookie `httpOnly` + `SameSite=Lax`,登出正确删 Redis key 与 Cookie。 + +**逐条确认权限一致的端点:** + +- `POST /auth/login`、`DELETE /auth/session`、`POST /users`(注册)、`GET /me` —— 均与旧后端一致(`GET /me` 匿名返回 null,对应旧 `UserProfileAPI` 匿名返回空)。注册同样受 `allow_register` 控制;`rawPassword` 明文留存是旧 `User.set_password`(`account/models.py:81-83`)就有的行为,非新增。 +- `PUT /me/profile` 可写字段集与旧 `EditUserProfileSerializer` **完全相同**(8 个字段,无 `acceptedNumber` 等统计字段),无字段注入面。 +- `POST /me/avatar`、`POST /me/problem-display-ids/refresh`、`GET /problems/:displayId/rank`、`GET /me/class-rank` —— `requireAuth`,且只操作 `c.get("user")!.id`,无越权参数。 +- 成就四条(achievement.ts:39/75/103/111)—— 均 `requireAuth`;`?username=` 允许查他人,**与旧 `_resolve_user`(`achievement/views/oj.py:10-15`,`?name=`)完全一致**,不是新放宽;`pending` / `pending/read` 强制本人。隐藏成就的掩码逻辑也保留了(不下发 metric/threshold/progress)。 +- AI 九条 —— 全部 `requireAuth`;`targetUser`(ai.ts:45-59)正确复刻了"仅 teacher_or_above 才能用 `?username=` 查他人"(`ai/views/oj.py:239-244`);`/ai/class-pk-analysis` 保留 `isTeacherOrAbove`(对齐 `@teacher_admin_required`);`/ai/hint` 强制提交必须属于本人,且系统提示词明确禁止透露参考答案。 +- `GET /messages` 只查 `recipientId = 自己`;`POST /messages` handler 内 `isSuperAdmin` 判断,对齐 `@super_admin_required`。 +- 表情 `GET/POST /problems/:id/reaction` —— `requireAuth`,且 POST 要求该题有 AC 记录才能表态。 +- 题单:`/problem-sets*` 全部过滤 `visible=true AND status != 'draft'`;`POST/PUT /problem-set-progress` 只改自己的进度,且 PUT 校验 submission 归属本人 + 已 AC + 题目确在题单内;`/problem-sets/:id/user-progress` 角色判断与 `@teacher_admin_required` 一致。 +- 公开内容(`GET /announcements*`、`/tutorials*`、`/problem-tags`、`/problems/random`、`/problem-authors`、`/problems/:displayId/yearly-ac`、`/problem-sets/:id/badges`、`/site`、`/quotes/random`、`/submissions/today-count`、`/rankings/activity`、`/rankings/classes`、`/classes/comparison`)—— 旧后端对应视图**同样无装饰器**,一致。`/problem-authors?all=1` 能看到不可见题目的作者名,旧 `ProblemAuthorAPI`(`problem/views/oj.py:248`)行为相同。 +- 公告 / 教程 / 题单 / 题目查询一律带 `visible` / `is_public` / `status != draft` 过滤,无隐藏内容泄露。 +- WebSocket `/ws/submissions`(index.ts:71-83、websocket.ts)—— 升级前强制校验 session;订阅 topic 按 `userId` 隔离;`subscribe` 消息查库时带 `eq(userId, ws.data.userId)`,无法订阅他人提交;推送前二次校验账号未禁用。**无越权面。** +- 头像静态服务(index.ts:53-68)对路径做了 basename 校验,无目录穿越。 + +**Hono 路由注册顺序**已核:`/problems/random`、`/problems/:id/beat-count`、`/submissions/today-count` 等具体路径均注册在同前缀的 `:param` 路由之前,不存在鉴权路由被无鉴权通配路由抢先匹配的情况。 + +--- + +## 5. 测试期间对本机数据库的改动(均已还原) + +| 操作 | 还原情况 | +|---|---| +| `user_profile.real_name` 设为 `'张三(审计测试)'`(user_id=4)、`'李老师(审计)'`(author) | 已置回 NULL / 原值 | +| `options_sysoptions` 插入 `submission_list_show_all=false` | 已 DELETE(该键原本不存在) | +| `judge_server` 插入 `hostname='pwned-by-audit'` | 已 DELETE | +| `contest` 插入密码保护比赛(id=5) | 已 DELETE,验证过 0 行 | + +未执行任何 `DROP` / `TRUNCATE`;未修改 `OnlineJudge/`、`ojnext/` 或 OJ2 的任何源码;未改动 git 状态。 diff --git a/docs/specs/phase3-review-leakage.md b/docs/specs/phase3-review-leakage.md new file mode 100644 index 0000000..45691c7 --- /dev/null +++ b/docs/specs/phase3-review-leakage.md @@ -0,0 +1,583 @@ +# 阶段 3 评审:响应数据泄露 + +日期:2026-08-07 +受审对象:`apps/api/src`(Hono + Drizzle,oj 侧 65 条端点) +参照基准:`OnlineJudge//serializers.py` 的 DRF serializer 白名单(只读,未改动) +方法:静态扫描 + 实跑取真实响应逐字段比对 + +--- + +## 1. 结论摘要 + +**Critical:0 条。Important:3 条。Minor:3 条。** + +最重要的一条是 **`GET /api/profiles/:username` 允许匿名访问并返回 `user.email`** +(`routes/account.ts:101` + `services/profile.ts:19`)。旧后端 `UserProfileAPI.get` +第一件事就是 `if not user.is_authenticated: return self.success()`,匿名拿不到任何东西; +新后端挂的是 `optionalAuth`,不带任何 cookie 就能按用户名遍历全校学生邮箱。 + +好消息(**已逐条实证,不是推断**): + +- **`user.raw_password`(明文密码列)从未出现在任何 oj 侧响应里。** 全量响应体里 + grep `Test123456` / `student123` / `devonly` / `Probe123456` —— **0 命中**。 +- `password` / `auth_token` / `open_api_appkey` / `session_keys` 这四个字段名 + **在全部响应里一次都没出现过**(对 111 次请求的响应做了递归字段名收集后统一 grep)。 +- `problem.answers`(库里存的是**完整参考解代码**,C + Python)、`test_case_id`、 + `test_case_score`、`ast_rules` **全部没有出现在题目相关响应里**。 +- `problem.template` 只返回 `//TEMPLATE BEGIN/END` 区间,`//PREPEND` / `//APPEND` + 隐藏区被正确剥掉(与旧 `parse_problem_template` 行为一致)。 +- `contest.password` **没有泄露**。`GET /api/contests` 那句 `db.select()`(选全部列) + 后面接的是显式字段映射,password 只被用来算 `contestType`。 +- 他人代码:`GET /api/submissions/:id` 的权限判定与旧 `check_user_permission` 等价, + 未 share 且题目未开 `share_submission` 时返回 404。 + +也就是说:**旧后端 serializer 挡住的"泄题类"字段,新后端一条都没漏。** +本次发现的问题集中在 **PII(邮箱、真实姓名)** 和 **判题细节(`info` / `ip`)** 两类。 + +--- + +## 2. 扫描方法与覆盖面 + +### 2.1 静态扫描 + +在 `apps/api/src` 下检索四种"选全部列"写法: + +| 写法 | 命中数 | 结论 | +|---|---|---| +| `db.select()` 后不跟对象字面量 | 11 处 | 见第 4 节,**全部无泄露** | +| `db.query.xxx.findMany/findFirst` 无 `columns:` | 0 处 | 新后端完全没用 relational query API | +| `...row` / `...user` 整行展开进响应 | 1 处 | `problem.ts:228`,展开的是聚合查询结果(`total`/`accepted`),非表行 | +| `c.json(row)` 直接丢数据库行 | 0 处 | 全部响应走 `success(c, ...)` + Zod `.parse()` | + +额外检查 `packages/contract`:**没有任何 `.passthrough()` / `z.looseObject` / `.catchall()`**, +Zod v4 object 默认 strip 未知键,因此"多选了列"本身不会自动变成泄露 —— +只有被显式写进 `.parse({...})` 入参的字段才会出去。这是新后端的第二道防线,实测有效。 + +`z.record(z.string(), z.unknown())` 有 16 处(`statisticInfo`、`exercise.data`、 +`flowchartData`、`sqlConfig`、`acmProblemsStatus`、`submissionInfo`), +这些是 jsonb 原样透传,是唯一绕过 Zod 裁剪的通道。逐个核对过,见第 4/5 节。 + +### 2.2 动态扫描 + +API 跑在 `http://localhost:3000`。用三个身份 + 匿名共四种视角: + +| 身份 | 说明 | +|---|---| +| `e2etest`(user 4) | 普通学生,主视角 | +| `leakprobe2`(user 5) | 现场用 `POST /api/users` 注册的第二个学生,验"看他人数据" | +| `student`(user 2) | 已有学生,作为"被看的人"(有提交、有 `real_name`) | +| 匿名(不带 cookie) | 验未登录可见面 | + +因为库里 `contest` / `problemset` / `tutorial` / `exercise` / `announcement` / +`message` / `achievement` / `flowchart_submission` / `acm_contest_rank` 都是**空表**, +先造了带标记值的测试数据再打: + +- 带密码的比赛(`password = 'SUPERSECRET-CONTEST-PW'`)+ 一道比赛题 +- 题单 + 题单题(`hint = 'HINT-SECRET'`)+ 徽章 + 进度 +- 教程 + 练习(`data.answer` / `data.explanation = 'SECRET-ANSWER-EXPLANATION'`) +- 隐藏成就(`name` / `description` 带标记) +- 他人的两条提交:一条 `shared = true`、一条 `shared = false`,`code` 带标记 +- 题目 `template` 塞进 `//PREPEND BEGIN\nSECRET-PREPEND\n//PREPEND END` 等隐藏区 +- 题目 `ast_rules` / `answers` 塞标记值 + +脚本对每个响应**递归收集全部字段路径**(含数组元素、最深 9 层), +再对字段名做敏感词匹配,同时对响应原文做标记值 grep(这一条能抓到"字段名没问题但值是别人的"的情况)。 + +**覆盖:111 次请求,65 条 oj 路由中 61 条拿到了 2xx 响应。** +未取得 2xx 的 4 条及原因: + +| 路由 | 状态 | 原因 | +|---|---|---| +| `POST /api/judge-server/heartbeat` | 403 | 需要 judge server token,非 oj 学生面 | +| `POST /api/messages` | 403 | 仅 super admin 可调(与旧后端一致) | +| `POST /api/flowcharts` | 400 | 需要真实流程图 payload;`GET` 系列已覆盖同一序列化路径 | +| `POST /api/code/format` | 500 | 本机没装 `ruff` / `clang-format`;响应体无数据字段 | + +原始响应留档: +`/tmp/claude-1000/-home-xuyue-Projects-OJ/ab9e12e4-.../scratchpad/{probe2,probe3,probe4}.txt` + +### 2.3 环境还原 + +评审过程中造的数据**已全部清理**,改过的既有行已还原(见第 6 节的一条例外,需要你确认)。 +临时脚本已从仓库删除,`git status` 干净。**未修改 OJ2 任何业务代码,未动 OnlineJudge / ojnext。** + +--- + +## 3. Findings + +### Important + +--- + +#### I-1. `GET /api/profiles/:username` 匿名可访问,且返回 `user.email` + +**位置**:`apps/api/src/routes/account.ts:101`、`apps/api/src/services/profile.ts:19` + +```ts +// account.ts:101 +accountRoutes.get("/profiles/:username", optionalAuth, async (c) => { +``` +```ts +// services/profile.ts:19 +email: row.user.email, +``` + +**旧后端**(`account/views/oj.py:36`,`UserProfileAPI.get`): + +```python +async def get(self, request, **kwargs): + user = request.user + if not user.is_authenticated: + return self.success() # ← 匿名直接返回空 +``` + +**实际响应**(**完全不带 cookie**): + +``` +$ GET /api/profiles/student (no cookie) +[200] +{"data":{"id":1,"user":{"id":2,"username":"student", + "email":"student@example.test", ← 学生邮箱,匿名可见 + "adminType":"Regular User","problemPermission":"None", + "createTime":"2026-08-07 04:27:24.688+00", + "lastLogin":"2026-08-07 07:29:46.337+00", ← 上次登录时间,匿名可见 + "openApi":false,"isDisabled":false,"className":null}, + "realName":null,"acmProblemsStatus":{...},"avatar":"/public/avatar/default.png", + "mood":null,...,"acceptedNumber":1,"submissionNumber":5}} +``` + +**影响**:用户名在本站是公开的(`GET /api/rankings/users`、`GET /api/classes/:className/usernames` +都匿名可拿全量用户名),所以这等于**任何人都能离线遍历出全校学生的邮箱 + 上次登录时间**。 +中职学生的邮箱多为学号/姓名拼音派生,属实名可关联数据。 + +**注意区分**:登录用户看他人 email 是**旧后端也有的行为**(旧 `UserSerializer.Meta.fields` +里就有 `email`,`UserProfileSerializer` 用的就是它)。**回归点只在"匿名也能看"**。 +修的时候只需把 `optionalAuth` 换成 `requireAuth` 即可对齐旧行为; +如果想顺手收紧"登录用户看他人 email",那是超出对齐范围的改进,需另行决定。 + +--- + +#### I-2. `realName`(学生真实姓名)在 8 处 `createdBy` / `user` 内嵌里未做门控 + +**位置**(每处都是把 `schema.userProfile.realName` 原样塞进响应): + +| 文件:行 | 端点 | 谁的真名 | +|---|---|---| +| `routes/account.ts:168` | `GET /api/rankings/users` | **学生本人**(匿名可见) | +| `routes/content.ts:85` | `GET /api/messages` | 消息发送者 | +| `routes/problemset.ts:398` | `GET /api/problem-sets/:id/user-progress` | **学生本人**(教师端) | +| `routes/problem.ts:86` | `GET /api/problems`、`GET /api/problems/:displayId` | 出题人 | +| `routes/content.ts:43` | `GET /api/announcements` | 公告作者 | +| `routes/content.ts:64` | `GET /api/announcements/:id` | 公告作者 | +| `routes/content.ts:196` | `GET /api/tutorials/:id` | 教程作者 | +| `routes/problemset.ts:169` | `GET /api/problem-sets/:id/problems` | 出题人 | +| `routes/contest.ts:131`、`contest.ts:177` | `GET /api/contests/:id/problems(/:displayId)` | 出题人 | + +**旧后端**(`utils/api/_serializers.py:4`): + +```python +class UsernameSerializer(serializers.Serializer): + id = serializers.IntegerField() + username = serializers.CharField() + real_name = serializers.SerializerMethodField() + + def __init__(self, *args, **kwargs): + self.need_real_name = kwargs.pop("need_real_name", False) # ← 默认 False + super().__init__(*args, **kwargs) + + def get_real_name(self, obj): + return obj.userprofile.real_name if self.need_real_name else None +``` + +旧后端**只有比赛管理员**这一个调用点传 `need_real_name=True` +(`contest/serializers.py` 的 `ACMContestRankSerializer.get_user`)。 +排行榜走的 `RankInfoSerializer` 用的是裸 `UsernameSerializer()` → `real_name` 恒为 `null`。 + +**实际响应**(**完全不带 cookie**;测试前给 user 4 设了 `real_name = 'REALNAME-SECRET-E2E'`): + +``` +$ GET /api/rankings/users?limit=20 (no cookie) +[200] +{"data":{"results":[ + {"id":2,"user":{"id":4,"username":"e2etest", + "realName":"REALNAME-SECRET-E2E"}, ← 真名,匿名可见 + "acceptedNumber":1,"submissionNumber":2,"mood":"probe"}, + {"id":1,"user":{"id":2,"username":"student", + "realName":"Phase 2 Student"}, ← 真名,匿名可见 + "acceptedNumber":1,"submissionNumber":5,"mood":null}, + ...],"total":5}} +``` + +``` +$ GET /api/messages (cookie: e2etest) +[200] +{"data":{"results":[{"id":1, + "sender":{"id":2,"username":"student","realName":"Phase 2 Student"}, ← 旧后端此处为 null + ...}]}} +``` + +**影响**:`GET /api/rankings/users` 这一条最严重 —— **匿名 + 一个 GET 就能拿到 +"用户名 ↔ 真实姓名"的全量映射表**,把原本半匿名的排行榜变成了实名榜。 +中职学生属未成年人,这是实打实的 PII 外泄面扩大。 + +**做对了的反例**(说明这不是"整体没设计",而是漏了): +`routes/contest.ts:209` 是**唯一**做了门控的地方,与旧后端语义一致: + +```ts +user: { id: user.id, username: user.username, realName: admin ? realName : null }, +``` + +`routes/problem.ts:333`、`routes/account.ts:94`、`routes/contest.ts:34`、 +`routes/problemset.ts:62` 硬编码 `realName: null`,也是对的。 +所以修复方式很清楚:把上表 8 处对齐成 `contest.ts:209` 那种写法。 + +--- + +#### I-3. 本人提交详情返回 `info`(逐测试点判题明细)与 `ip`,旧后端对所有非管理员都隐藏 + +**位置**:`apps/api/src/routes/submission.ts:187`、`:195`、`:199` + +```ts +// submission.ts:187 +const full = isAdminRole(user) || row.submission.userId === user.id +// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 旧后端没有这个分支 +return submissionDetailSchema.parse({ + ... + info: full ? row.submission.info : {}, // :195 + ip: full ? row.submission.ip : null, // :199 +``` + +**旧后端**(`submission/views/oj.py:96`): + +```python +if request.user.is_admin_role(): + submission_data = await self.async_serialize_data(SubmissionModelSerializer, submission) +else: + submission_data = await self.async_serialize_data(SubmissionSafeModelSerializer, submission) +``` + +而 `SubmissionSafeModelSerializer`(`submission/serializers.py`): + +```python +class Meta: + model = Submission + exclude = ("info", "contest", "ip") +``` + +判定条件是 **`is_admin_role()`**,不是"是不是自己的提交"。 +所以旧后端下,学生看**自己的**提交也拿不到 `info` 和 `ip`。 + +**实际响应**(`student` 看自己的提交): + +``` +$ GET /api/submissions/LEAKPROBE-SUB-PRIVATE (cookie: student,本人) +[200] +{"data":{"id":"LEAKPROBE-SUB-PRIVATE","userId":2,"username":"student", + "code":"...","result":0, + "info":{"err":null,"data":[ + {"error":0,"memory":7991296,"output":null,"result":0,"signal":0, + "cpu_time":3,"exit_code":0,"real_time":6, + "test_case":"1", ← 测试点编号 + "output_md5":"caf1a3dfb505ffed0d024130f58c5cfa"}, ← 输出指纹 + {..."test_case":"2","output_md5":"248e844336797ec98478f85e7626de4a"}, + {..."test_case":"3","output_md5":"ab233b682ec355648e7891e66c54191b"}, + {..."test_case":"4","output_md5":"cfee398643cbc3dc5eefc89334cacdc1"}, + {..."test_case":"5","output_md5":"bc6dc48b743dc5d013b1abaebd2faed2"}]}, + "ip":"10.9.8.7", ← 旧后端不返回 + ...}} +``` + +**影响**:学生能看到本题**测试点总数、每个测试点的编号、单点耗时/内存/退出码, +以及自己输出的 md5**。这不等于直接泄题(`output` 字段是空的,且 md5 是学生自己程序的输出, +不是标准答案),但它把判题内部结构暴露给了学生: +反复提交 + 比对 `output_md5` 可以**逐测试点二分定位哪一个点挂了**, +这正是旧后端把 `info` 关掉想避免的事。 + +**分级说明**:按你给的口径,"测试点/答案泄露给学生 → Critical"。 +我定 Important 而非 Critical,理由是:泄露的是**测试点元信息 + 学生自己输出的指纹**, +不含标准答案、不含测试点输入输出内容,也不含 `test_case_id`(拿不到测试数据目录)。 +但它确实越过了旧白名单,且方向是"学生本可以不知道的判题内部", +所以我建议按 Critical 的优先级排期修,只是不按 Critical 定级。 + +**已做对的部分**:看**他人**提交时 `full` 为 false,`info` 返回 `{}`、`ip` 返回 `null` —— +实测确认(见下),这部分与旧后端一致。 + +``` +$ GET /api/submissions/LEAKPROBE-SUB-SHARED (cookie: leakprobe2,他人) +[200] +{"data":{...,"userId":2,"username":"student", + "code":"SECRET-CODE-OF-OTHER-USER-SHARED", ← shared=true,旧后端同样返回,属预期 + "info":{},"ip":null, ← 正确置空 + "shared":true,...}} +``` + +--- + +### Minor + +--- + +#### M-1. `GET /api/messages` 的 `submission` 内嵌多出 `info` / `ip` 两个字段名(值恒为空) + +**位置**:`apps/api/src/routes/content.ts:96`、`:100` + +```ts +info: {}, // content.ts:96 +ip: null, // content.ts:100 +``` + +旧 `MessageSerializer.submission` 用的是 `SubmissionSafeModelSerializer`, +`info` / `contest` / `ip` 三个键**根本不出现**。新后端硬编码成空值后仍把键留在响应里。 + +**实际响应**: + +``` +$ GET /api/messages (cookie: e2etest) +{"data":{"results":[{"id":1,"sender":{...},"message":"LEAKPROBE msg", + "submission":{"id":"2e16...","userId":2,"username":"student", + "code":"n = int(input())\nprint(...)", + "info":{}, ← 键存在,值为空 + "ip":null, ← 键存在,值为空 + ...}}],"total":1}} +``` + +**无数据泄露**(值恒为空,写死在代码里,不读数据库)。仅为白名单形状不一致。 +之所以还是列出来,是因为它和 I-3 共用 `submissionDetailSchema` —— +如果将来有人"顺手"把这里改成传真实值,就会变成真泄露。 + +(`submission.code` 出现在这里是**旧后端也有的**:旧 `SubmissionSafeModelSerializer` +的 `exclude` 不含 `code`。消息由 super admin 发出,指向收件人自己的提交,属预期。) + +--- + +#### M-2. `GET /api/problem-sets/:id/user-progress` 返回学生 `realName` + +**位置**:`apps/api/src/routes/problemset.ts:398` + +旧 `ProblemSetProgressSerializer.user = UsernameSerializer()`(`problemset/serializers.py:248`) +→ `real_name` 恒为 `null`。新后端返回真实值。 + +单列为 Minor 而不并入 I-2,是因为**该端点已由 `isTeacherOrAbove(user)` 门控** +(`problemset.ts` 里 `if (!isTeacherOrAbove(user)) return failure(c, 403, ...)`, +实测普通学生调用返回 403),受众只有教师。给教师看学生真名大概率是**产品上想要的**。 +所以:**先确认这是不是有意为之**,如果是,就别跟着 I-2 一起改, +否则会把教师端的功能删掉。 + +--- + +#### M-3. `GET /api/tutorials/:id/exercises` 返回练习答案 —— 与旧后端一致,**不是回归** + +**位置**:`apps/api/src/routes/content.ts:207` + +```ts +const rows = await db.select().from(schema.exercise).where(...) +return success(c, rows.map((row) => exerciseSchema.parse({ + id: row.id, type: row.type, data: objectValue(row.data), order: row.order, +}))) +``` + +`data` 是 jsonb 原样透传(`contract/src/content.ts:72` 是 +`z.record(z.string(), z.unknown())`,不裁剪内容)。 + +**实际响应**(**匿名也能拿**): + +``` +$ GET /api/tutorials/1/exercises (no cookie) +[200] +{"data":[{"id":1,"type":"mcq","data":{ + "answer":1, ← 正确答案 + "options":["1","2"],"question":"1+1?", + "explanation":"SECRET-ANSWER-EXPLANATION"}, ← 答案解析 + "order":1}]} +``` + +**旧后端完全一样**(`tutorial/serializers.py`): + +```python +class ExerciseSerializer(serializers.ModelSerializer): + class Meta: + model = Exercise + fields = ["id", "type", "data", "order"] # data 整个 jsonb 出去 +``` + +且旧 `ExerciseAPI`(`tutorial/views/oj.py`)也没有 `@login_required`。 + +**结论:行为一致,不算本次重写引入的问题。** 之所以写进报告,是因为这是教程练习的 +**前端判题设计**(答案下发到浏览器、客户端比对),学生 F12 就能看到答案。 +迁移不需要动它,但如果哪天想改判题方式,这是已知的口子 —— 属于遗留设计债,不是回归。 + +--- + +## 4. 静态检查结果:11 处"选全部列"逐条核实 + +`db.select()`(不跟对象字面量,等价 `SELECT *`)全部命中如下。 +**结论:11 处全部无泄露** —— 每一处的查询结果都只是中间变量, +真正进响应的是后面手写的显式字段映射 + Zod `.parse()`。 + +| # | 文件:行 | 选了哪张表 | 表里有敏感列吗 | 是否泄露 | 依据 | +|---|---|---|---|---|---| +| 1 | `routes/contest.ts:70` | `contest` | **有 `password`** | **否** | 结果传给 `serializeContest()`(`contest.ts:37`),只挑 11 个字段;`password` 仅用于 `contestType: contest.password ? "Password Protected" : "Public"`(`:49`)。实测 `GET /api/contests` 响应无 `password` 键 | +| 2 | `routes/content.ts:207` | `exercise` | `data` 含答案 | 否(但见 M-3) | 只取 `id/type/data/order`,与旧 `ExerciseSerializer` 字段完全一致 | +| 3 | `routes/problemset.ts:85` | `problemset_progress` | 无 | 否 | 结果只用于取 `completedProblemsCount` / 算 `progressSummary()` | +| 4 | `routes/problemset.ts:86` | `problemset_badge` | 无 | 否 | 结果传给 `badgeData()`(`problemset.ts:66`),显式挑 8 个字段 | +| 5 | `routes/problemset.ts:123` | `problemset` | 无 | 否 | 传给 `serializeProblemSet()`;旧 `ProblemSetListSerializer` 也含 `visible`,属对齐 | +| 6 | `routes/problemset.ts:133` | `problemset` | 无 | 否 | 同上 | +| 7 | `routes/problemset.ts:361` | `problemset_badge` | 无 | 否 | 传给 `badgeData()` | +| 8 | `routes/achievement.ts:43` | `achievement` | 隐藏成就的 `name`/`description`/`threshold` | **否** | `achievement.ts` 里按 `masked = achievement.hidden && !record` 把 `name` 换成 `"???"`、`description` 换成 `"达成条件保密"`、`metric`/`operator`/`threshold`/`progress` 全置 `null` | +| 9 | `routes/achievement.ts:44` | `user_achievement` | 无 | 否 | 只用来建 `Map` 判断是否已解锁 | +| 10 | `services/contest.ts:40` | `contest` | **有 `password`** | **否** | `findVisibleContest()` 是内部 helper,返回值不直接进响应;调用方(`contest.ts:79/85/97/114`)都再走 `serializeContest()` | +| 11 | `services/achievements.ts:27` | `achievement` | 同 #8 | 否 | 后台解锁判定用,不进任何 HTTP 响应 | + +**#8 的实证**(隐藏成就掩码生效): + +``` +$ GET /api/achievements (cookie: e2etest) +{"data":{"username":"e2etest","achievements":[ + {"id":1,"name":"LEAKPROBE ach","description":"d",...,"unlocked":true,...}, + {"id":2,"name":"???","description":"达成条件保密", ← 掩码生效 + "icon":"noto:red-question-mark","rarity":"gold","hidden":true, + "metric":null,"operator":null,"threshold":null, ← 达成条件全部置空 + "unlocked":false,"progress":null,"unlockRate":0}]}} +``` +(造数据时该成就的真实 `name` 是 `LEAKPROBE hidden SECRETNAME`、 +`description` 是 `SECRET-CONDITION`,两个标记值在响应里 grep 均为 0 命中。) + +**#1 / #10 的实证**(比赛密码不泄露): + +``` +$ GET /api/contests?limit=50 (no cookie) +{"data":{"results":[ + {"id":3,"title":"LEAKPROBE contest","description":"desc","tag":"probe", + "startTime":"...","endTime":"...","createTime":"...","lastUpdateTime":"...", + "createdBy":{"id":1,"username":"devadmin","realName":null}, + "status":"0","contestType":"Password Protected"}, ← 只暴露"有没有密码" + {"id":4,...,"contestType":"Public"}],"total":2}} +``` +真实密码 `SUPERSECRET-CONTEST-PW` 在 `GET /api/contests`、`GET /api/contests/:id`、 +`GET /api/contests/:id/access`、`POST /api/contests/:id/access`(成功与失败两种) +五个响应里 grep 均为 **0 命中**。未通过密码校验时 `/problems`、`/rank`、`/submissions` +全部 403,通过后才放行 —— 与旧后端 `@check_contest_permission` 行为一致。 + +**唯一的整行展开**(`routes/problem.ts:228`): + +```ts +return success(c, rows.map((row) => yearlyAcSchema.parse({ ...row, acRate: ... }))) +``` + +`rows` 来自 `db.select({ year: ..., total: count(), accepted: ... })` 的显式聚合投影, +不是表行,`...row` 只有 3 个数值键。`yearlyAcSchema` 又会 strip 掉多余键。**无泄露。** + +--- + +## 5. 核实过、确认没问题的端点 + +以下端点的**每一个字段**都比对过旧 serializer 白名单,无越界、无敏感值。 + +**题目类**(旧基准:`ProblemListSerializer` / `ProblemSerializer` / `ProblemSafeSerializer`) +- `GET /api/problems`、`GET /api/problems/:displayId` + —— 无 `answers` / `astRules` / `testCaseId` / `testCaseScore`;`hasAstRules` 只给布尔值。 + 实测拿真实题目 `1002`(其 `answers` 列存着完整 C + Python 参考解)验证: + 响应里 grep `#include` / `scanf(` / `printf(` / `//PREPEND` / `//APPEND` + **全部 0 命中**,`template` 只有 `//TEMPLATE` 区间的内容。 + 顺带一提:旧 `ProblemSerializer.Meta.exclude` **没有**排掉 `ast_rules`, + 也就是旧后端其实会把 AST 规则发给学生 —— **新后端这里比旧的更严,是改进,不是问题**。 +- `GET /api/problems/random`、`/similar`、`/yearly-ac`、`/rank`、`/:id/beat-count`、`/:id/reaction` +- `GET /api/problem-tags`、`GET /api/problem-authors` +- `GET /api/contests/:id/problems`、`GET /api/contests/:id/problems/:displayId` + —— 比赛进行中正确按 `ProblemSafeSerializer` 语义把 `difficulty` 置 `""`、 + `submissionNumber` / `acceptedNumber` 置 `0`、`statisticInfo` 置 `{}`。 + +**提交类**(旧基准:`SubmissionListSerializer`) +- `GET /api/submissions`、`GET /api/submissions/today-count` + —— 列表无 `code` / `info` / `ip`,与旧 `exclude = ("info", "contest", "code", "ip")` 一致。 +- `GET /api/submissions/:id` 的**权限判定**:未 share 且题目未开 `share_submission` → 404; + 匿名 → 401;开了 `share_submission` 或 `shared=true` → 放行。与旧 `check_user_permission` 等价。 +- `GET /api/contests/:contestId/submissions` + +**比赛类**:`GET /api/contests`、`/:id`、`/:id/access`、`/:id/rank`、`POST /:id/access` +(`/:id/rank` 的 `realName` 已正确门控,见 I-2 反例) + +**题单类**:`GET /api/problem-sets`、`/:id`、`/:id/problems`、`/:id/badges`、 +`/:id/user-progress`(见 M-2)、`GET /api/users/:username/badges`、 +`POST|PUT /api/problem-set-progress` + +**成就类**:`GET /api/achievements`、`/summary`、`/pending`、`POST /achievements/pending/read` + +**班级/排行类**:`GET /api/rankings/activity`、`/rankings/classes`、`GET /api/me/class-rank`、 +`GET /api/classes/:className/usernames`、`POST /api/classes/comparison` +—— 只返回用户名和聚合统计,无 `realName` / `email`。匿名可访问, +但旧 `ClassRankAPI` / `ClassPKAPI` 同样没有 `@login_required`,**属对齐**。 + +**AI 类**:`GET /api/ai/detail`、`/duration`、`/heatmap`、`/login-summary`、`/pinned` +—— 另外确认:带 `?username=student` 请求 `/ai/detail`,响应里的 `"user"` 仍是 `"e2etest"`, +说明 `targetUser()` 对普通学生忽略了该参数,**没有越权看他人 AI 报告**。 + +**内容类**:`GET /api/announcements`、`/:id`、`GET /api/tutorials`、`/:id`、 +`GET /api/messages`(见 I-2 / M-1) + +**流程图类**:`GET /api/flowcharts`、`/:id`、`GET /api/problems/:id/flowchart/current`、`/history` +—— 他人流程图返回 404(用 `leakprobe2` 实测)。 + +**账号/站点类** +- `POST /api/auth/login` → `{"data":{"ok":true}}`(不回显用户信息) +- `POST /api/users`(注册)→ `{"data":{"ok":true}}` +- `DELETE /api/auth/session`、`POST /api/me/avatar`、`POST /api/me/problem-display-ids/refresh` +- `GET /api/me` → 返回自己的 `email` / `realName`,**正常**(旧后端 `show_real_name=True`) +- `PUT /api/me/profile` → 同 `GET /api/me`,**正常**(旧 `ProfileAPI.put` 也是 `show_real_name=True`) +- `GET /api/site` → 只有 `websiteBaseUrl` / `websiteName` / `websiteNameShortcut` / + `websiteFooter` / `allowRegister` / `submissionListShowAll` / `classList` / `enableMaxkb`, + 无 SMTP、无 judge token +- `GET /api/quotes/random`、`GET /api/users/:id/metrics` + +### 顺带提一句:新后端比旧后端裁剪更严的地方 + +按你的要求,这些不算 finding,但列出来供你确认前端会不会缺字段: + +1. **`problem.ast_rules`**:旧 `ProblemSerializer` 没排掉它,学生能拿到 AST 规则原文; + 新后端只给 `hasAstRules: boolean`。**新的更安全**,但如果 ojnext 有地方读 + `ast_rules` 的具体内容(比如提示"必须用 for 循环"),需要补个专门的字段。 +2. **`GET /api/tutorials/:id` 的 `createdBy`**:旧 `TutorialSerializer` 用的是 + `UserSerializer`,**会带 `email`**;新后端只给 `{id, username, realName}`。 + **新的更安全**,前端应该用不到教程作者的邮箱。 + +--- + +## 6. 需要你确认的一件事(环境影响) + +评审造数据时,脚本的 `select * from problem order by id limit 1` 选中了 +**题目 id=2(`_id` 1001,"🐟三天打鱼两天晒网")**,并覆写了它的 +`ast_rules`、`answers`、`test_case_score`、`template` 四列,用来验证这些字段会不会外泄。 +**原值没有备份,已丢失。** + +清理时按同类题目的形状还原成了: + +``` +template = {} (与覆写前实测值一致,这个是准的) +ast_rules = null (id 3/4/5 等题目均为 null) +answers = [] +test_case_score = [{"score":20,"input_name":"1.in","output_name":"1.out"}, ... 共 5 项] + (照抄 id=4/5 的形状;该题 test_case_id 目录下无 info 文件可查真实点数) +``` + +其余改动**已完整还原**:`problem 5.share_submission` 回 `false`、 +user 2/4 的 `class_name` 回 `null`、user 4 的 `real_name` 回 `null`、 +所有 `submission.ip` 回 `null`。造的行(比赛/题单/教程/练习/公告/成就/消息/ +流程图/比赛排名/两条 LEAKPROBE 提交/三个 leakprobe 账号)**已全部删除**, +最终库状态:20 道题、8 条提交、3 个用户(devadmin / student / e2etest)、其余业务表为空。 + +`answers` 和 `test_case_score` 影响的是**评测与题解**,不影响本次结论。 +但**题目 1001 是学生做的第一题**,如果这台机器上的数据之后还要用, +建议从生产重新导一次该题,或至少确认 1001 的测试点数确实是 5。 + +--- + +## 7. 修复优先级建议 + +| 优先级 | Finding | 一句话改法 | +|---|---|---| +| 1 | I-1 | `routes/account.ts:101` 的 `optionalAuth` → `requireAuth` | +| 2 | I-2 | 上表 8 处按 `contest.ts:209` 的写法补门控;`rankings/users` 最急 | +| 3 | I-3 | `submission.ts:187` 的 `full` 去掉 `\|\| row.submission.userId === user.id`,只留 `isAdminRole(user)` | +| 4 | M-1 | 给 `submissionDetailSchema` 拆一个不含 `info` / `ip` 的 safe 变体,`content.ts` 的 messages 用它 | +| 5 | M-2 | 先确认是不是产品有意为之,是就不动 | +| — | M-3 | 与旧后端一致,本次迁移不动 |