两份评审独立进行、互不知情,各自命中同两条问题(匿名可读用户档案、 realName 无条件下发),独立复现提高可信度。严重度取更严一方 —— 使用者是中职学生,姓名邮箱班级属个人信息。 3 Critical / 3 Important,F1-F3 已由控制方独立实跑复现。 无敏感字段泄露、无泄题,比赛权限重建得最好。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
29 KiB
阶段 3 评审:响应数据泄露
日期:2026-08-07
受审对象:apps/api/src(Hono + Drizzle,oj 侧 65 条端点)
参照基准:OnlineJudge/<app>/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 侧响应里。 全量响应体里 grepTest123456/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
// account.ts:101
accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
// services/profile.ts:19
email: row.user.email,
旧后端(account/views/oj.py:36,UserProfileAPI.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/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):
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 是唯一做了门控的地方,与旧后端语义一致:
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
// 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)
而 SubmissionSafeModelSerializer(submission/serializers.py):
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
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
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):
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):
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<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/reactionGET /api/problem-tags、GET /api/problem-authorsGET /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/refreshGET /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 tokenGET /api/quotes/random、GET /api/users/:id/metrics
顺带提一句:新后端比旧后端裁剪更严的地方
按你的要求,这些不算 finding,但列出来供你确认前端会不会缺字段:
problem.ast_rules:旧ProblemSerializer没排掉它,学生能拿到 AST 规则原文; 新后端只给hasAstRules: boolean。新的更安全,但如果 ojnext 有地方读ast_rules的具体内容(比如提示"必须用 for 循环"),需要补个专门的字段。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 | 与旧后端一致,本次迁移不动 |