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,10 +14,22 @@
* | 旧防护 | 新做法 |
* |---|---|
* | authorizer 禁 ATTACH防读写服务器任意 SQLite 文件) | WASM 没有宿主文件系统绑定ATTACH **结构上**够不到宿主,只能碰随进程消失的虚拟 FS |
* | authorizer 白名单让查询题只读 | `PRAGMA query_only=1`SQLite 原生只读开关 |
* | progress_handler 墙钟超时 | 子进程外部 SIGKILLOS 级,比指令计数更硬)+ 语句间 deadline 检查 |
* | setlimit(LIMIT_LENGTH) 防单值撑爆内存 | 子进程 `ulimit -v`,触顶时 WASM 抛可捕获错误 |
* | max_page_count | 原样保留 |
* | authorizer 白名单让查询题只读 | `PRAGMA query_only=1` **加上逐语句拒绝学生的 PRAGMA**,见 runStudent |
* | progress_handler 墙钟超时 | 语句**之间**查 deadline + 子进程外部 SIGKILL 兜底,见下 |
* | setlimit(LIMIT_LENGTH) 防单值撑爆内存 | 子进程 `ulimit -d`,触顶时 WASM 抛可捕获错误 |
* | max_page_count | 保留,且每条学生语句前重放一遍(否则学生能自己调大) |
*
* 两处必须知道的削弱:
*
* 1. **只有 query_only 是不够的。** 它自己就是个 PRAGMA学生一句 `PRAGMA query_only=0`
* 就能关掉它 —— 旧实现的 authorizer 把 SQLITE_PRAGMA 一律拒了,所以没这个洞。
* 这里靠 runStudent 的逐语句守卫补上:学生 SQL 里的 PRAGMA 一律拒绝。
* 2. **超时粒度是「一条语句」。** deadline 只在语句之间查,单条语句(递归 CTE、
* 大 CROSS JOIN一旦进了 step() 就没法从 JS 里打断。stock sql.js 的 wasm 没导出
* sqlite3_progress_handler / sqlite3_interrupt / sqlite3_set_authorizer / sqlite3_limit
* (已核对导出表,别再去找了),要用就得自己编 wasm。所以真正的硬上限是父进程的
* SIGKILL`./index.ts` 收到 `@phase:student` 标记后会把兜底时限收到「题目时限 + 2s」
* 跑飞的学生语句最多多占这么久,而不是整个作业预算。
*/
import initSqlJs, { type Database, type SqlJsStatic } from "sql.js"
@@ -98,36 +110,74 @@ interface ResultSet {
rows: string[]
}
function newDatabase(SQL: SqlJsStatic, memoryLimitMb: number) {
const db = new SQL.Database()
/** 引擎侧的资源限制。学生的每条语句前都要重放一遍,见 runStudent */
function applyLimits(db: Database, memoryLimitMb: number) {
const limit = Math.max(Math.trunc(memoryLimitMb), 1)
db.run("PRAGMA page_size=4096")
// 4096B/页 × 256 页/MB超限报 "database or disk is full"
db.run(`PRAGMA max_page_count=${limit * 256}`)
}
function newDatabase(SQL: SqlJsStatic, memoryLimitMb: number) {
const db = new SQL.Database()
db.run("PRAGMA page_size=4096")
applyLimits(db, memoryLimitMb)
return db
}
/** sql.js 的 iterateStatements 没进 @types/sql.js这里补上类型 */
interface PreparedStatement {
step(): boolean
get(): unknown[]
getColumnNames(): string[]
getSQL(): string
getNormalizedSQL(): string
free(): void
}
function iterate(db: Database, script: string): Iterable<PreparedStatement> {
return (db as unknown as {
iterateStatements(sql: string): Iterable<PreparedStatement>
}).iterateStatements(script)
}
/**
* 取语句的首关键字。优先用 sqlite3_normalized_sql —— 归一化由 SQLite 自己做,
* 注释、大小写、空白都已抹平(`/*x*/ pragma Query_Only = 0` → `PRAGMA query_only=?`
* 比在原文上自己做词法猜测可靠得多。
*/
function leadingKeyword(statement: PreparedStatement) {
let text = ""
try {
text = statement.getNormalizedSQL() ?? ""
} catch {
text = ""
}
// 万一这个 build 没开 SQLITE_ENABLE_NORMALIZE退回到原文剥注释
if (!text) {
text = statement.getSQL().replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ")
}
return text.trimStart().split(/[\s(;]/, 1)[0]?.toUpperCase() ?? ""
}
/**
* 逐条执行,返回最后一条产生结果集的语句的 (列数, 行);无结果集返回 null。
*
* 用 sql.js 的 iterateStatements底层是 sqlite3_prepare_v2 逐条推进),
* 比旧实现手写的分号切分更准 —— 字符串和注释里的分号天然不会误切。
*
* `guard` 在每条语句 step 之前调用,用来拦学生的 PRAGMA 并重放限制。
*/
function executeStatements(db: Database, script: string, deadline: number): ResultSet | null {
function executeStatements(
db: Database,
script: string,
deadline: number,
guard?: (statement: PreparedStatement) => void,
): ResultSet | null {
let last: ResultSet | null = null
for (const statement of (db as unknown as {
iterateStatements(sql: string): Iterable<{
step(): boolean
get(): unknown[]
getColumnNames(): string[]
free(): void
}>
}).iterateStatements(script)) {
if (Date.now() > deadline) {
statement.free()
throw new Error("interrupted")
}
for (const statement of iterate(db, script)) {
try {
if (Date.now() > deadline) throw new Error("interrupted")
guard?.(statement)
const names = statement.getColumnNames()
if (names.length > 0) {
const rows: string[] = []
@@ -195,11 +245,27 @@ function executeTrusted(db: Database, script: string, deadline: number, prefix:
}
/** 带防护执行学生 SQL异常映射为学生级 JudgeStatus */
function runStudent(db: Database, script: string, mode: string, deadline: number) {
function runStudent(
db: Database,
script: string,
mode: string,
deadline: number,
memoryLimitMb: number,
) {
// 查询题只读PRAGMA query_only 是 SQLite 原生开关,替代旧实现的 authorizer 白名单
if (mode === "query") db.run("PRAGMA query_only=1")
try {
const last = executeStatements(db, script, deadline)
const last = executeStatements(db, script, deadline, (statement) => {
// query_only 自己就是个 PRAGMA不拦 PRAGMA 的话学生一句 `PRAGMA query_only=0`
// 就把只读关掉了。旧实现的 authorizer 把 SQLITE_PRAGMA 一律拒掉,这里对齐它。
// 教学场景下学生也没有用 PRAGMA 的正当需求,两种题型一律拒。
if (leadingKeyword(statement) === "PRAGMA") {
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, "禁止使用 PRAGMA 语句")
}
// 兜底:万一漏掉某种改设置的写法,限制在每条语句前都重放一遍
applyLimits(db, memoryLimitMb)
if (mode === "query") db.run("PRAGMA query_only=1")
})
if (mode === "query") return last
return dumpTables(db)
} catch (error) {
@@ -250,8 +316,21 @@ export interface RunCaseOptions {
orderSensitive: boolean
timeLimitMs: number
memoryLimitMb: number
/** 阶段回调,子进程据此写 stderr 标记,父进程据此收紧兜底 SIGKILL 时限 */
onPhase?: (phase: "prepare" | "student") => void
}
/**
* 受信脚本(初始化 + 标准答案)**合计**的墙钟预算。
* 父进程按同一口径算兜底时限,两边必须用这一个函数,别各写各的。
*/
export function trustedBudgetMs(timeLimitMs: number) {
return Math.max(timeLimitMs * 5, 10_000)
}
/** 题目页展示数据的墙钟预算 */
export const DISPLAY_BUDGET_MS = 10_000
export interface CaseResult {
test_case: string
result: JudgeStatusValue
@@ -276,14 +355,17 @@ export async function runCase(
options: RunCaseOptions,
): Promise<CaseResult> {
const SQL = await sqlEngine()
// 受信脚本的运行上限放宽,避免出题数据较大时误报;仍防子进程永久阻塞
const trustedLimitMs = Math.max(options.timeLimitMs * 5, 10_000)
options.onPhase?.("prepare")
// 受信脚本的运行上限放宽,避免出题数据较大时误报;仍防子进程永久阻塞。
// 三段受信执行(两次初始化 + 一次标准答案)共用同一个 deadline
// 这样"受信阶段总耗时"有确定上限,父进程才能算出匹配的兜底时限。
const trustedDeadline = Date.now() + trustedBudgetMs(options.timeLimitMs)
let expected: unknown
const refDb = newDatabase(SQL, options.memoryLimitMb)
try {
executeTrusted(refDb, initSql, Date.now() + trustedLimitMs, "初始化脚本执行失败")
const last = executeTrusted(refDb, refSql, Date.now() + trustedLimitMs, "标准答案执行失败")
executeTrusted(refDb, initSql, trustedDeadline, "初始化脚本执行失败")
const last = executeTrusted(refDb, refSql, trustedDeadline, "标准答案执行失败")
if (options.mode === "query") {
expected = last
} else {
@@ -317,10 +399,17 @@ export async function runCase(
let actual: unknown
let elapsed = 0
try {
executeTrusted(studentDb, initSql, Date.now() + trustedLimitMs, "初始化脚本执行失败")
executeTrusted(studentDb, initSql, trustedDeadline, "初始化脚本执行失败")
options.onPhase?.("student")
const start = Date.now()
try {
actual = runStudent(studentDb, studentSql, options.mode, start + options.timeLimitMs)
actual = runStudent(
studentDb,
studentSql,
options.mode,
start + options.timeLimitMs,
options.memoryLimitMb,
)
} catch (error) {
elapsed = Date.now() - start
const failure = error as SqlCaseError
@@ -399,7 +488,7 @@ export async function buildDisplay(
) {
const SQL = await sqlEngine()
const db = newDatabase(SQL, memoryLimitMb)
const deadline = Date.now() + 10_000
const deadline = Date.now() + DISPLAY_BUDGET_MS
try {
executeTrusted(db, initSql, deadline, "初始化脚本执行失败")
const tables = dumpDisplayTables(db)
@@ -407,11 +496,7 @@ export async function buildDisplay(
if (mode === "query") {
let expected: unknown = null
try {
for (const statement of (db as unknown as {
iterateStatements(sql: string): Iterable<{
step(): boolean; get(): unknown[]; getColumnNames(): string[]; free(): void
}>
}).iterateStatements(refSql)) {
for (const statement of iterate(db, refSql)) {
try {
const names = statement.getColumnNames()
if (names.length === 0) { while (statement.step()) { /* 无结果集 */ } ; continue }
@@ -443,7 +528,7 @@ export async function buildDisplay(
}
const before = dumpTables(db)
executeTrusted(db, refSql, Date.now() + 10_000, "标准答案执行失败")
executeTrusted(db, refSql, deadline, "标准答案执行失败")
const after = dumpTables(db)
const changed = new Set<string>()
for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) {