Files
OJ2/docs/specs/phase3-review-leakage.md
yuetsh 9c04b00e3f docs(阶段3): 权限与泄露双评审报告 + 合并修复清单
两份评审独立进行、互不知情,各自命中同两条问题(匿名可读用户档案、
realName 无条件下发),独立复现提高可信度。严重度取更严一方 ——
使用者是中职学生,姓名邮箱班级属个人信息。

3 Critical / 3 Important,F1-F3 已由控制方独立实跑复现。
无敏感字段泄露、无泄题,比赛权限重建得最好。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 01:43:55 -06:00

29 KiB
Raw Blame History

阶段 3 评审:响应数据泄露

日期2026-08-07 受审对象:apps/api/srcHono + Drizzleoj 侧 65 条端点) 参照基准:OnlineJudge/<app>/serializers.py 的 DRF serializer 白名单(只读,未改动) 方法:静态扫描 + 实跑取真实响应逐字段比对


1. 结论摘要

Critical0 条。Important3 条。Minor3 条。

最重要的一条是 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 + Pythontest_case_idtest_case_scoreast_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/findFirstcolumns: 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 处(statisticInfoexercise.dataflowchartDatasqlConfigacmProblemsStatussubmissionInfo 这些是 jsonb 原样透传,是唯一绕过 Zod 裁剪的通道。逐个核对过,见第 4/5 节。

2.2 动态扫描

API 跑在 http://localhost:3000。用三个身份 + 匿名共四种视角:

身份 说明
e2etestuser 4 普通学生,主视角
leakprobe2user 5 现场用 POST /api/users 注册的第二个学生,验"看他人数据"
studentuser 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 = falsecode 带标记
  • 题目 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 需要真实流程图 payloadGET 系列已覆盖同一序列化路径
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:101apps/api/src/services/profile.ts:19

// account.ts:101
accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
// services/profile.ts:19
email: row.user.email,

旧后端account/views/oj.py:36UserProfileAPI.get

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/usersGET /api/classes/:className/usernames 都匿名可拿全量用户名),所以这等于任何人都能离线遍历出全校学生的邮箱 + 上次登录时间。 中职学生的邮箱多为学号/姓名拼音派生,属实名可关联数据。

注意区分:登录用户看他人 email 是旧后端也有的行为(旧 UserSerializer.Meta.fields 里就有 emailUserProfileSerializer 用的就是它)。回归点只在"匿名也能看"。 修的时候只需把 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/problemsGET /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:131contest.ts:177 GET /api/contests/:id/problems(/:displayId) 出题人

旧后端utils/api/_serializers.py:4

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.pyACMContestRankSerializer.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唯一做了门控的地方,与旧后端语义一致:

user: { id: user.id, username: user.username, realName: admin ? realName : null },

routes/problem.ts:333routes/account.ts:94routes/contest.ts:34routes/problemset.ts:62 硬编码 realName: null,也是对的。 所以修复方式很清楚:把上表 8 处对齐成 contest.ts:209 那种写法。


I-3. 本人提交详情返回 info(逐测试点判题明细)与 ip,旧后端对所有非管理员都隐藏

位置apps/api/src/routes/submission.ts:187:195:199

// 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

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)

SubmissionSafeModelSerializersubmission/serializers.py

class Meta:
    model = Submission
    exclude = ("info", "contest", "ip")

判定条件是 is_admin_role(),不是"是不是自己的提交"。 所以旧后端下,学生看自己的提交也拿不到 infoip

实际响应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 为 falseinfo 返回 {}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/messagessubmission 内嵌多出 info / ip 两个字段名(值恒为空)

位置apps/api/src/routes/content.ts:96:100

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 出现在这里是旧后端也有的:旧 SubmissionSafeModelSerializerexclude 不含 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:248real_name 恒为 null。新后端返回真实值。

单列为 Minor 而不并入 I-2是因为该端点已由 isTeacherOrAbove(user) 门控 problemset.tsif (!isTeacherOrAbove(user)) return failure(c, 403, ...) 实测普通学生调用返回 403受众只有教师。给教师看学生真名大概率是产品上想要的。 所以:先确认这是不是有意为之,如果是,就别跟着 I-2 一起改, 否则会把教师端的功能删掉。


M-3. GET /api/tutorials/:id/exercises 返回练习答案 —— 与旧后端一致,不是回归

位置apps/api/src/routes/content.ts:207

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:72z.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

class ExerciseSerializer(serializers.ModelSerializer):
    class Meta:
        model = Exercise
        fields = ["id", "type", "data", "order"]     # data 整个 jsonb 出去

且旧 ExerciseAPItutorial/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 && !recordname 换成 "???"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}]}}

(造数据时该成就的真实 nameLEAKPROBE hidden SECRETNAMEdescriptionSECRET-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-PWGET /api/contestsGET /api/contests/:idGET /api/contests/:id/accessPOST /api/contests/:id/access(成功与失败两种) 五个响应里 grep 均为 0 命中。未通过密码校验时 /problems/rank/submissions 全部 403通过后才放行 —— 与旧后端 @check_contest_permission 行为一致。

唯一的整行展开routes/problem.ts:228

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/problemsGET /api/problems/:displayId —— 无 answers / astRules / testCaseId / testCaseScorehasAstRules 只给布尔值。 实测拿真实题目 1002(其 answers 列存着完整 C + Python 参考解)验证: 响应里 grep #include<stdio.h> / 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-tagsGET /api/problem-authors
  • GET /api/contests/:id/problemsGET /api/contests/:id/problems/:displayId —— 比赛进行中正确按 ProblemSafeSerializer 语义把 difficulty""submissionNumber / acceptedNumber0statisticInfo{}

提交类(旧基准:SubmissionListSerializer

  • GET /api/submissionsGET /api/submissions/today-count —— 列表无 code / info / ip,与旧 exclude = ("info", "contest", "code", "ip") 一致。
  • GET /api/submissions/:id权限判定:未 share 且题目未开 share_submission → 404 匿名 → 401开了 share_submissionshared=true → 放行。与旧 check_user_permission 等价。
  • GET /api/contests/:contestId/submissions

比赛类GET /api/contests/:id/:id/access/:id/rankPOST /:id/access /:id/rankrealName 已正确门控,见 I-2 反例)

题单类GET /api/problem-sets/:id/:id/problems/:id/badges/:id/user-progress(见 M-2GET /api/users/:username/badgesPOST|PUT /api/problem-set-progress

成就类GET /api/achievements/summary/pendingPOST /achievements/pending/read

班级/排行类GET /api/rankings/activity/rankings/classesGET /api/me/class-rankGET /api/classes/:className/usernamesPOST /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/:idGET /api/tutorials/:idGET /api/messages(见 I-2 / M-1

流程图类GET /api/flowcharts/:idGET /api/problems/:id/flowchart/current/history —— 他人流程图返回 404leakprobe2 实测)。

账号/站点类

  • POST /api/auth/login{"data":{"ok":true}}(不回显用户信息)
  • POST /api/users(注册)→ {"data":{"ok":true}}
  • DELETE /api/auth/sessionPOST /api/me/avatarPOST /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/randomGET /api/users/:id/metrics

顺带提一句:新后端比旧后端裁剪更严的地方

按你的要求,这些不算 finding但列出来供你确认前端会不会缺字段

  1. problem.ast_rules:旧 ProblemSerializer 没排掉它,学生能拿到 AST 规则原文; 新后端只给 hasAstRules: boolean新的更安全,但如果 ojnext 有地方读 ast_rules 的具体内容(比如提示"必须用 for 循环"),需要补个专门的字段。
  2. GET /api/tutorials/:idcreatedBy:旧 TutorialSerializer 用的是 UserSerializer会带 email;新后端只给 {id, username, realName}新的更安全,前端应该用不到教程作者的邮箱。

6. 需要你确认的一件事(环境影响)

评审造数据时,脚本的 select * from problem order by id limit 1 选中了 题目 id=2_id 1001"🐟三天打鱼两天晒网",并覆写了它的 ast_rulesanswerstest_case_scoretemplate 四列,用来验证这些字段会不会外泄。 原值没有备份,已丢失。

清理时按同类题目的形状还原成了:

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_submissionfalse、 user 2/4 的 class_namenull、user 4 的 real_namenull、 所有 submission.ipnull。造的行(比赛/题单/教程/练习/公告/成就/消息/ 流程图/比赛排名/两条 LEAKPROBE 提交/三个 leakprobe 账号)已全部删除 最终库状态20 道题、8 条提交、3 个用户devadmin / student / e2etest、其余业务表为空。

answerstest_case_score 影响的是评测与题解,不影响本次结论。 但题目 1001 是学生做的第一题,如果这台机器上的数据之后还要用, 建议从生产重新导一次该题,或至少确认 1001 的测试点数确实是 5。


7. 修复优先级建议

优先级 Finding 一句话改法
1 I-1 routes/account.ts:101optionalAuthrequireAuth
2 I-2 上表 8 处按 contest.ts:209 的写法补门控;rankings/users 最急
3 I-3 submission.ts:187full 去掉 || row.submission.userId === user.id,只留 isAdminRole(user)
4 M-1 submissionDetailSchema 拆一个不含 info / ip 的 safe 变体,content.ts 的 messages 用它
5 M-2 先确认是不是产品有意为之,是就不动
M-3 与旧后端一致,本次迁移不动