Files
OJ2/docs/specs/phase3-review-authz.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

25 KiB
Raw Blame History

阶段 3 权限与鉴权边界评审

日期2026-08-07 受审对象:apps/apiHono + Bunoj 侧 65 条端点 参照基准:../OnlineJudgeDjango + DRF只读 工作清单:docs/specs/phase3-coverage.md 的「oj 侧逐条对照」表 验证方式:逐条读源码 + 对本机 http://localhost:3000 实跑(测试账号 e2etest,普通学生)


1. 结论摘要

3 条 Critical2 条 Important4 条 Minor。

整体上权限骨架是忠实重建的:比赛的 check_contest_permission含密码保护、未开始拦截、contest admin 豁免、ProblemSafeSerializer 的字段脱敏)、提交的 check_user_permission、流程图的所有者判断、角色判定函数(is_admin_role / is_teacher_or_above / is_contest_admin)都与旧后端逐行等价,实跑验证也全部对上。我事先怀疑的四处里有三处是误判——GET /contests/:idGET /classes/:className/usernamesGET /users/:id/metrics 在旧后端本来就是无鉴权的,新后端没有放宽;判题机心跳也确实做了 token 校验,只是校验用的密钥有问题。

真正的问题集中在**"序列化层的隐式权限":旧后端把权限判断藏在 DRF 序列化器的默认参数里(UsernameSerializer(need_real_name=False)UserProfileAPI 开头的 is_authenticated 短路),新后端重写时只搬了装饰器、没搬序列化器里的这层,于是真实姓名和邮箱被无差别下发给未登录访客**。学生是中职生(未成年人),真名 + 用户名 + 班级可以直接拼出花名册,这是本次最严重的问题。

另有一处逻辑翻转:submission_list_show_all=false 时,未登录用户能拿到全站提交列表,而登录的学生拿到空列表——匿名比登录更有权限。


2. 逐条对照表(只列有差异的行)

未列出的条目 = 新旧权限要求一致,见第 4 节。

# 旧端点 旧权限要求 新路由 新权限实现 判定
1 account/profileGET 视图首行 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() —— 匿名走到这行会 AttributeErrorDjango AnonymousUser 无该方法),必定拿不到数据 GET /submissions optionalAuthisRegularUser(null) === false → 守卫不生效,直接返回全量 更松Critical
4 announcement/announcement AnnouncementSerializer.created_by = UsernameSerializer()real_name: null GET /announcementsGET /announcements/:id createdBy.realName 无条件下发content.ts:43、64 更松
5 contest/contestscontest/contest ContestSerializer.created_by = UsernameSerializer()null GET /contestsGET /contests/:id creator() 返回 realNamecontest.ts:31-34 → :47 更松
6 problem/problem(列表与详情) ProblemListSerializer.created_by = UsernameSerializer()null GET /problemsGET /problems/:displayId/similar listItem() 下发 realNameproblem.ts:86 更松
7 problem/contest/problem 同上 GET /contests/:id/problems.../:displayId contest.ts:131、:177 下发 realName 更松
8 problemset/problemset/<id>/<id>/problems UsernameSerializer()null GET /problem-sets* problemset.ts:62、:169 下发 realName 更松
9 problemset/<id>/users_progress @teacher_admin_required + ProblemSetProgressSerializer.user = UsernameSerializer()即使教师也拿不到 real_name GET /problem-sets/:id/user-progress requireAuth + isTeacherOrAbove(角色判断一致),但 problemset.ts:398 下发 realName 角色一致,字段更松
10 message/messageGET @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/submissionPOST @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-108optionalAuth 服务层apps/api/src/services/profile.ts:6-41 旧后端OnlineJudge/account/views/oj.py:38-41

async def get(self, request, **kwargs):
    user = request.user
    if not user.is_authenticated:
        return self.success()          # ← 匿名直接返回空,等价 @login_required

新后端只用了 optionalAuthhandler 内部没有任何 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:168GET /rankings/users,无任何中间件)

user: { id: user.id, username: user.username, realName: profile.realName },

旧后端OnlineJudge/account/serializers.py:145-150 + OnlineJudge/utils/api/_serializers.py:9-14

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:34creator(),经 :47 GET /contestsGET /contests/:id
routes/problem.ts:86listItem() GET /problemsGET /problems/:displayId/similar
routes/problemset.ts:62problemSetCreator() GET /problem-setsGET /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 /messagessender.realName 需登录
routes/problemset.ts:398 GET /problem-sets/:id/user-progress 需教师

注:routes/problem.ts:333GET /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

if (!(await getBooleanOption("submission_list_show_all", true)) && isRegularUser(user)) {
  return success(c, submissionListSchema.parse({ results: [], total: 0 }))
}

isRegularUser 定义于 routes/helpers.ts:27-29user?.adminType === "Regular User"匿名用户 user === null → 返回 false → 守卫短路,全量下发。

旧后端OnlineJudge/submission/views/oj.py:149-151

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_sysoptionssubmission_list_show_all 置为 false,然后不带 Cookie 请求 GET /api/submissions?limit=10

影响:该开关的唯一用途就是"考试期间不让学生互相看提交"。现在学生只要开一个隐私窗口(或直接删 Cookie就能绕过开关等于失效。


Important


I1 — 判题机共享密钥的默认值硬编码在仓库里fail-open

位置apps/api/src/config.ts:8

judgeServerToken: process.env.JUDGE_SERVER_TOKEN ?? "oj2-dev-token",

.env.example:4 同样写着 JUDGE_SERVER_TOKEN=oj2-dev-token

旧后端OnlineJudge/options/options.py:92-94

def default_token():
    token = os.environ.get("JUDGE_SERVER_TOKEN")
    return token if token else rand_str()      # ← 没配就随机fail-safe

生产 composeOnlineJudge/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

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-33Boolean(user && user.adminType !== "Regular User")。 旧:account/models.py:65-70 显式列举 [STUDENT_ADMIN, TEACHER_ADMIN, SUPER_ADMIN]。 当前四种角色下两者等价,但将来新增任何角色(如"助教""家长")都会默认获得 admin 权限,包括 canViewSubmission 里的"看所有人代码"。建议改回白名单。

M2 — 比赛权限判断没有中间件兜底 canAccessContestcontest.ts:116/144/193submission.ts:63/253 共 5 处手工调用。旧后端用 @check_contest_permission 装饰器,漏挂会很显眼;新后端漏调一次就是静默放行,且 GET /contests/:id/problems 这类路由挂的是 optionalAuth(本身不拦人),从中间件列表上完全看不出它受保护。目前 5 处都调对了,属可维护性风险。

M3 — blog / github 从 URLField 降级为自由字符串 packages/contract/src/account.ts:15,17z.string().max(256);旧 account/serializers.py:125,127serializers.URLField。现在 PUT /me/profile 可以写入 javascript:alert(1) 之类的值,是否可利用取决于前端如何渲染这两个字段。

M4 — GET /dev/problems 仍在线且无鉴权 routes/problem.ts:231。内容本身是公开题目摘要,无实际泄露,但它是阶段 1 的临时端点,phase3-coverage.md 第 120 行已标注应删除,至今还挂在生产路由树上。

(文档纠错,非安全问题) phase3-coverage.md:96PUT submissions/:id 描述为"判题结果写回"。实际实现(submission.ts:301-316)是提交分享开关,对应旧 SubmissionAPI.put + ShareSubmissionSerializer。判题结果写回走的是内部 worker不经 HTTP。这条描述会让人误以为存在一个需要判题机凭据的写入端点建议改正。


4. 核实过、确认没有问题的项

以下是逐条读过源码(多数还实跑过)、确认新后端权限要求与旧后端等价或更严的部分。

四个预先怀疑点里的三个是误判:

  • GET /contests/:idcontest.ts:78——旧 ContestAPI.getcontest/views/oj.py:38-51)本来就没有任何装饰器,公开可读。新后端行为一致,且 serializeContest 不下发 password(旧 ContestSerializerexclude)。真正受保护的比赛内容(题目、榜单、提交)全部走 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/usernamessite.ts:36——旧 ClassUsernamesAPIconf/views.py:237-243)无装饰器,公开。新后端一致,且多加了 ^\d{3,4}$ 格式校验。属旧后端遗留的开放面,不是本次重写引入的(但确实是 C1 的放大器,值得单独排期收口)。

  • GET /users/:id/metricsaccount.ts:140——旧 Metricsaccount/views/oj.py:66-83)无装饰器,按 ?userid= 任取,也不限制只查自己。新后端一致。同上,属遗留开放面。

  • POST /judge-server/heartbeat——token 校验存在且用了 timingSafeEqual,比旧后端的裸字符串比较更好;问题只在默认值(见 I1

比赛权限(最复杂的一块,逐条对齐):

  • canAccessContestservices/contest.ts:45-63)与 check_contest_permission._check_accessaccount/decorators.py:122-136)逐行等价:未登录 → login-requiredcontest admin 豁免;密码保护校验 session 内存的密码;check_type != "details" 时拦截未开始的比赛。
  • checkContestPasswordservices/contest.ts:28-37)完整复刻了 sig#timestamp 形式(decorators.py:87-110),含 sha256 前 8 位与过期判断。
  • contestDetailsAllowedservices/contest.ts:24-26= Contest.problem_details_permissioncontest/models.py:42-43)。
  • 比赛未结束时对非管理员脱敏的字段集完全一致:difficultysubmissionNumberacceptedNumberstatisticInfo 被置空 —— 正是旧 ProblemSafeSerializerproblem/serializers.py:211-228exclude 的那几个(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-53ipAllowedservices/contest.ts:71-85的 CIDR 计算正确,空列表放行、无 IP 拒绝。
  • GET/POST /contests/:id/access 均要求登录且比赛必须设了密码,与旧 ContestAccessAPI / ContestPasswordVerifyAPI(均 @login_required)一致。

提交与流程图的所有者判断:

  • canViewSubmissionsubmission.ts:166-178Submission.check_user_permissionsubmission/models.py:47-56)等价,且新增了 if (!user) return false —— 更严。
  • check_share=False 语义(用于分享开关和 canUnshare)正确传递。
  • GET /submissions/:idrequireAuth,无权限时返回 404 而非 403不泄露存在性 —— 比旧的 "No permission for this submission" 更严。
  • PUT /submissions/:id(分享开关)保留了"比赛进行中不得分享"。
  • 流程图 canViewflowchart.ts:24-26= FlowchartSubmission.check_user_permissionflowchart/models.py:59-63);列表的 myself / username / 普通用户默认只看自己 三段逻辑与 flowchart/views/oj.py:78-83 一致;current / history 强制 userId = 自己

角色与身份:

  • isRegularUser / isAdminRole / isTeacherOrAbove / isSuperAdminhelpers.ts:27-41account/models.py:53-73 语义一致(isAdminRole 的黑名单写法见 M1
  • isContestAdminservices/contest.ts:20-22= User.is_contest_adminaccount/models.py:78-79)。
  • 禁用账号:getUserByTokenauth/session.ts:105-108发现 isDisabled 立即删除 session 并返回 null比旧后端"每个装饰器里各判一次"更彻底。
  • Session token 用 randomBytes(32) + RedisCookie httpOnly + SameSite=Lax,登出正确删 Redis key 与 Cookie。

逐条确认权限一致的端点:

  • POST /auth/loginDELETE /auth/sessionPOST /users(注册)、GET /me —— 均与旧后端一致(GET /me 匿名返回 null对应旧 UserProfileAPI 匿名返回空)。注册同样受 allow_register 控制;rawPassword 明文留存是旧 User.set_passwordaccount/models.py:81-83)就有的行为,非新增。
  • PUT /me/profile 可写字段集与旧 EditUserProfileSerializer 完全相同8 个字段,无 acceptedNumber 等统计字段),无字段注入面。
  • POST /me/avatarPOST /me/problem-display-ids/refreshGET /problems/:displayId/rankGET /me/class-rank —— requireAuth,且只操作 c.get("user")!.id,无越权参数。
  • 成就四条achievement.ts:39/75/103/111—— 均 requireAuth?username= 允许查他人,与旧 _resolve_userachievement/views/oj.py:10-15?name=)完全一致,不是新放宽;pending / pending/read 强制本人。隐藏成就的掩码逻辑也保留了(不下发 metric/threshold/progress
  • AI 九条 —— 全部 requireAuthtargetUserai.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 能看到不可见题目的作者名,旧 ProblemAuthorAPIproblem/views/oj.py:248)行为相同。
  • 公告 / 教程 / 题单 / 题目查询一律带 visible / is_public / status != draft 过滤,无隐藏内容泄露。
  • WebSocket /ws/submissionsindex.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 状态。