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
co-authored by Claude Opus 5
parent e3faa689e7
commit 0f94e7ecbd
11 changed files with 859 additions and 67 deletions
+11 -2
View File
@@ -27,8 +27,16 @@ export type SqlJob =
}
| { kind: "display"; initSql: string; refSql: string; mode: "query" | "modify" }
/**
* 写阶段标记。必须用 writeSync:父进程正是靠这个标记决定「多久之后 SIGKILL」
* 以及「超时算谁的」,走异步 stderr 的话标记可能还在缓冲里就被杀掉了。
*/
function markPhase(phase: string) {
process.stderr.write(`@phase:${phase}\n`)
const bytes = new TextEncoder().encode(`@phase:${phase}\n`)
let written = 0
while (written < bytes.length) {
written += writeSync(2, bytes, written, bytes.length - written)
}
}
/**
@@ -61,12 +69,13 @@ async function main() {
const display = await buildDisplay(job.initSql, job.refSql, job.mode)
finish({ ok: true, display })
}
markPhase("judge")
// 阶段由 runCase 内部回调标记:prepare(受信脚本)→ student(学生 SQL
const result = await runCase(job.initSql, job.refSql, job.studentSql, {
mode: job.mode,
orderSensitive: job.orderSensitive,
timeLimitMs: job.timeLimitMs,
memoryLimitMb: job.memoryLimitMb,
onPhase: markPhase,
})
finish({ ok: true, case: result })
} catch (error) {