fix(阶段4): 两份独立安全评审的 Critical/Important 全部修掉

## 权限边界评审(后台 86 个 handler)

守卫本身一个没漏,问题全在对象级归属校验:

- C1 跨题单删奖章:先按 (id, problemsetId) 校验奖章归属再删,
  否则拿自己的题单 id + 别人的奖章 id 就能把别人的 user_badge 删掉
- C2 make-public 无归属校验:补 canEdit,越权者拿不到题面
- I1 两个分析端点被 `/problems/:id` 遮蔽 —— Hono 按注册顺序匹配,
  不是静态优先。挪到 `/problem-analytics/*`
- I2 from-public 只校验目标比赛归属:源题也必须是公开题库题
- I3 克隆比赛回传原比赛明文密码:克隆一律 password: null
- I4 upload-image 守卫比旧后端严,教师写题面会 403:收回 requireAdmin

两个互不可见的教师账号实跑复验,六条全部拦住。

## SQL 判题沙箱评审

- I-1 查询题只读被一句 `PRAGMA query_only=0` 关掉,实测 DML 拿到 AC。
  query_only 自己就是个 PRAGMA,旧实现靠 authorizer 把 SQLITE_PRAGMA
  一律拒了才没这个洞。现在 runStudent 逐语句拦 PRAGMA(用
  sqlite3_normalized_sql 判关键字,注释和大小写由 SQLite 抹平),
  并在每条语句前重放 query_only 和 max_page_count 兜底。
  顺带修掉 M-1 里 max_page_count 学生可自行调大的部分。

- I-2 单条语句进了 step() 就打断不了,只能等父进程 SIGKILL,
  而兜底时限是整个作业一口价 25s —— 1s 限的题要 26s 才判 TLE,
  判题池只有 2 个槽,几发死循环就能把所有人堵住。
  改成分阶段:子进程用 stderr 报 prepare/student/display,
  父进程边读边换表,一进学生 SQL 就把兜底收到「题目时限 + 2s」。
  实测 26s → 3.06s。

  归因也跟着修了:卡在受信脚本(出题人的初始化脚本、标准答案)
  现在报 SYSTEM_ERROR,不再当成学生超时甩 TLE。

engine.ts 头部那张防护对照表按实测重写 —— 原来那版把 query_only
写成等价于 authorizer 白名单,是不成立的。另记一笔:stock sql.js 的
wasm 没导出 progress_handler / interrupt / set_authorizer / limit,
想要得自己编,别再去翻了。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 21:59:42 -06:00
parent e3faa689e7
commit 0f94e7ecbd
11 changed files with 859 additions and 67 deletions

View File

@@ -14,7 +14,7 @@ import { resolve } from "node:path"
import { count, desc, eq, gte, ilike, not, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { requireAdmin, requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { config } from "../../config"
import { db, schema } from "../../db"
import { publishConfigUpdate } from "../../events"
@@ -202,8 +202,12 @@ const MAX_IMAGE_BYTES = 10 * 1024 * 1024
* Simditor 富文本编辑器的图片上传。响应形状是编辑器约定的
* `{success, msg, filePath}`,不是本项目的 `{data}` 信封 —— 但外面仍然包一层 data
* 由前端 api 层解包,这样它和其它接口共用同一个错误处理拦截器。
*
* 守卫用 requireAdmin 而不是 requireSuperAdmin题面和比赛描述的富文本编辑器都调它
* admin/problem/detail.vue、admin/contest/detail.vue收成超管专属会让教师和
* 学生管理员插图直接 403。旧后端这里是**零装饰器**(匿名可传),那太松,取中间档。
*/
adminConfRoutes.post("/upload-image", requireSuperAdmin, async (c) => {
adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
const form = await c.req.formData().catch(() => null)
const image = form?.get("image")
if (!(image instanceof File)) {

View File

@@ -189,7 +189,11 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
title: original.contest.title,
description: original.contest.description,
tag: original.contest.tag,
password: original.contest.password,
// 不复制原比赛的密码。两个理由:一是克隆出来是一场新比赛、时间也是新的,
// 沿用旧密码意味着拿着旧密码的学生直接能进;二是本接口不校验归属
// (旧后端也不校验,教师可以拿别人的比赛做模板),复制过来就等于把别人的
// 比赛密码原样回传给调用者。克隆者自己重新设一个。
password: null,
// 克隆出来的一律不可见:时间是拍脑袋定的 10 分钟后,直接开放会让学生看到一场没准备好的赛
visible: false,
allowedIpRanges: original.contest.allowedIpRanges,

View File

@@ -495,6 +495,12 @@ adminProblemRoutes.post("/problems/:id/make-public", requireProblemPermission, a
const [problem] = await db.select().from(schema.problem).where(eq(schema.problem.id, id)).limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
// 归属校验不能少:这个接口会把整道题(含 answers 标准答案)复制出来并回传,
// 没有它,任何有出题权的人拿别人比赛题的 id 就能把题面和答案整份拿走。
// 旧后端同样缺这个校验,但它只 `return self.success()` 不带数据,泄露面比这里小。
if (!(await canEdit(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
if (!problem.contestId || problem.isPublic) {
return failure(c, 409, "already-public", "Already be a public problem")
}
@@ -546,6 +552,14 @@ adminProblemRoutes.post("/contests/:contestId/problems/from-public", requireProb
if (user.adminType !== "Super Admin" && contest.createdById !== user.id) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
// 源题必须是**公开题**,且要么已可见、要么是自己的。旧后端只按 id 取,不校验任何东西 ——
// 于是能把别人比赛里的题(或别人尚未公开的草稿)拖进自己比赛,进而读到 answers。
if (problem.contestId !== null) {
return failure(c, 400, "not-a-public-problem", "只能从公开题库添加题目")
}
if (!problem.visible && !(await canEdit(user, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
if (contestStatus(contest) === "-1") return failure(c, 409, "contest-ended", "Contest has ended")
const [duplicate] = await db.select({ id: schema.problem.id }).from(schema.problem)

View File

@@ -366,14 +366,20 @@ adminProblemSetRoutes.delete("/problem-sets/:id/badges/:badgeId", requireTeacher
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const badgeId = queryInteger(c.req.param("badgeId"), 0, { min: 1 })
const deleted = await db.transaction(async (tx) => {
await tx.delete(schema.userBadge).where(eq(schema.userBadge.badgeId, badgeId))
return tx.delete(schema.problemsetBadge).where(and(
// 必须先确认这枚奖章确实属于本题单,再动 user_badge。
// 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定题单,
// 于是「自己的题单 id + 别人的奖章 id」会真删掉别人的获奖记录
// 然后因为 problemset_badge 删了 0 行而返回 404 —— 事务已经 COMMIT数据没了却报「不存在」。
const [badge] = await db.select({ id: schema.problemsetBadge.id }).from(schema.problemsetBadge)
.where(and(
eq(schema.problemsetBadge.id, badgeId),
eq(schema.problemsetBadge.problemsetId, row.id),
)).returning({ id: schema.problemsetBadge.id })
)).limit(1)
if (!badge) return failure(c, 404, "badge-not-found", "奖章不存在")
await db.transaction(async (tx) => {
await tx.delete(schema.userBadge).where(eq(schema.userBadge.badgeId, badge.id))
await tx.delete(schema.problemsetBadge).where(eq(schema.problemsetBadge.id, badge.id))
})
if (deleted.length === 0) return failure(c, 404, "badge-not-found", "奖章不存在")
return success(c, null)
})

View File

@@ -187,7 +187,11 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (
// ---------------------------------------------------------------- 卡点题目 / AC 趋势
adminTagRoutes.get("/problems/stuck", requireTeacher, async (c) => {
// 路径特意不放在 /problems 下Hono 按**注册顺序**匹配(不是静态优先),
// 而 problem.ts 的 `GET /problems/:id` 先注册,会把 `/problems/stuck` 整个吃掉 ——
// 这两个 handler 曾经从未执行过,生效的还是那边的 requireProblemPermission 而非这里的
// requireTeacher而且完全没有报错。换个前缀结构上就不可能再被遮蔽。
adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
const failedFilter = sql`filter (where ${inArray(schema.submission.result, FAILED)})`
const rows = await db.select({
displayId: schema.problem.displayId,
@@ -212,7 +216,7 @@ adminTagRoutes.get("/problems/stuck", requireTeacher, async (c) => {
})))
})
adminTagRoutes.get("/problems/ac-trend", requireTeacher, async (c) => {
adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
const currentYear = new Date().getFullYear()
// 参数按旧后端的口径夹逼:越界一律回落到默认值,不报错
let sinceYear = queryInteger(c.req.query("sinceYear"), 2023)