两份评审独立进行、互不知情,各自命中同两条问题(匿名可读用户档案、 realName 无条件下发),独立复现提高可信度。严重度取更严一方 —— 使用者是中职学生,姓名邮箱班级属个人信息。 3 Critical / 3 Important,F1-F3 已由控制方独立实跑复现。 无敏感字段泄露、无泄题,比赛权限重建得最好。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
25 KiB
阶段 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、/<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/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
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,无任何中间件)
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: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
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
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
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
生产 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
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 vssubmission/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,CookiehttpOnly+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 /messageshandler 内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 状态。