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:
@@ -27,8 +27,16 @@ export type SqlJob =
|
|||||||
}
|
}
|
||||||
| { kind: "display"; initSql: string; refSql: string; mode: "query" | "modify" }
|
| { kind: "display"; initSql: string; refSql: string; mode: "query" | "modify" }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写阶段标记。必须用 writeSync:父进程正是靠这个标记决定「多久之后 SIGKILL」
|
||||||
|
* 以及「超时算谁的」,走异步 stderr 的话标记可能还在缓冲里就被杀掉了。
|
||||||
|
*/
|
||||||
function markPhase(phase: string) {
|
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)
|
const display = await buildDisplay(job.initSql, job.refSql, job.mode)
|
||||||
finish({ ok: true, display })
|
finish({ ok: true, display })
|
||||||
}
|
}
|
||||||
markPhase("judge")
|
// 阶段由 runCase 内部回调标记:prepare(受信脚本)→ student(学生 SQL)
|
||||||
const result = await runCase(job.initSql, job.refSql, job.studentSql, {
|
const result = await runCase(job.initSql, job.refSql, job.studentSql, {
|
||||||
mode: job.mode,
|
mode: job.mode,
|
||||||
orderSensitive: job.orderSensitive,
|
orderSensitive: job.orderSensitive,
|
||||||
timeLimitMs: job.timeLimitMs,
|
timeLimitMs: job.timeLimitMs,
|
||||||
memoryLimitMb: job.memoryLimitMb,
|
memoryLimitMb: job.memoryLimitMb,
|
||||||
|
onPhase: markPhase,
|
||||||
})
|
})
|
||||||
finish({ ok: true, case: result })
|
finish({ ok: true, case: result })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -14,10 +14,22 @@
|
|||||||
* | 旧防护 | 新做法 |
|
* | 旧防护 | 新做法 |
|
||||||
* |---|---|
|
* |---|---|
|
||||||
* | authorizer 禁 ATTACH(防读写服务器任意 SQLite 文件) | WASM 没有宿主文件系统绑定,ATTACH **结构上**够不到宿主,只能碰随进程消失的虚拟 FS |
|
* | authorizer 禁 ATTACH(防读写服务器任意 SQLite 文件) | WASM 没有宿主文件系统绑定,ATTACH **结构上**够不到宿主,只能碰随进程消失的虚拟 FS |
|
||||||
* | authorizer 白名单让查询题只读 | `PRAGMA query_only=1`,SQLite 原生只读开关 |
|
* | authorizer 白名单让查询题只读 | `PRAGMA query_only=1` **加上逐语句拒绝学生的 PRAGMA**,见 runStudent |
|
||||||
* | progress_handler 墙钟超时 | 子进程外部 SIGKILL(OS 级,比指令计数更硬)+ 语句间 deadline 检查 |
|
* | progress_handler 墙钟超时 | 语句**之间**查 deadline + 子进程外部 SIGKILL 兜底,见下 |
|
||||||
* | setlimit(LIMIT_LENGTH) 防单值撑爆内存 | 子进程 `ulimit -v`,触顶时 WASM 抛可捕获错误 |
|
* | setlimit(LIMIT_LENGTH) 防单值撑爆内存 | 子进程 `ulimit -d`,触顶时 WASM 抛可捕获错误 |
|
||||||
* | max_page_count | 原样保留 |
|
* | 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"
|
import initSqlJs, { type Database, type SqlJsStatic } from "sql.js"
|
||||||
@@ -98,36 +110,74 @@ interface ResultSet {
|
|||||||
rows: string[]
|
rows: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function newDatabase(SQL: SqlJsStatic, memoryLimitMb: number) {
|
/** 引擎侧的资源限制。学生的每条语句前都要重放一遍,见 runStudent */
|
||||||
const db = new SQL.Database()
|
function applyLimits(db: Database, memoryLimitMb: number) {
|
||||||
const limit = Math.max(Math.trunc(memoryLimitMb), 1)
|
const limit = Math.max(Math.trunc(memoryLimitMb), 1)
|
||||||
db.run("PRAGMA page_size=4096")
|
|
||||||
// 4096B/页 × 256 页/MB,超限报 "database or disk is full"
|
// 4096B/页 × 256 页/MB,超限报 "database or disk is full"
|
||||||
db.run(`PRAGMA max_page_count=${limit * 256}`)
|
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
|
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。
|
* 逐条执行,返回最后一条产生结果集的语句的 (列数, 行);无结果集返回 null。
|
||||||
*
|
*
|
||||||
* 用 sql.js 的 iterateStatements(底层是 sqlite3_prepare_v2 逐条推进),
|
* 用 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
|
let last: ResultSet | null = null
|
||||||
for (const statement of (db as unknown as {
|
for (const statement of iterate(db, script)) {
|
||||||
iterateStatements(sql: string): Iterable<{
|
|
||||||
step(): boolean
|
|
||||||
get(): unknown[]
|
|
||||||
getColumnNames(): string[]
|
|
||||||
free(): void
|
|
||||||
}>
|
|
||||||
}).iterateStatements(script)) {
|
|
||||||
if (Date.now() > deadline) {
|
|
||||||
statement.free()
|
|
||||||
throw new Error("interrupted")
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
|
if (Date.now() > deadline) throw new Error("interrupted")
|
||||||
|
guard?.(statement)
|
||||||
const names = statement.getColumnNames()
|
const names = statement.getColumnNames()
|
||||||
if (names.length > 0) {
|
if (names.length > 0) {
|
||||||
const rows: string[] = []
|
const rows: string[] = []
|
||||||
@@ -195,11 +245,27 @@ function executeTrusted(db: Database, script: string, deadline: number, prefix:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 带防护执行学生 SQL,异常映射为学生级 JudgeStatus */
|
/** 带防护执行学生 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 白名单
|
// 查询题只读:PRAGMA query_only 是 SQLite 原生开关,替代旧实现的 authorizer 白名单
|
||||||
if (mode === "query") db.run("PRAGMA query_only=1")
|
if (mode === "query") db.run("PRAGMA query_only=1")
|
||||||
try {
|
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
|
if (mode === "query") return last
|
||||||
return dumpTables(db)
|
return dumpTables(db)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -250,8 +316,21 @@ export interface RunCaseOptions {
|
|||||||
orderSensitive: boolean
|
orderSensitive: boolean
|
||||||
timeLimitMs: number
|
timeLimitMs: number
|
||||||
memoryLimitMb: 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 {
|
export interface CaseResult {
|
||||||
test_case: string
|
test_case: string
|
||||||
result: JudgeStatusValue
|
result: JudgeStatusValue
|
||||||
@@ -276,14 +355,17 @@ export async function runCase(
|
|||||||
options: RunCaseOptions,
|
options: RunCaseOptions,
|
||||||
): Promise<CaseResult> {
|
): Promise<CaseResult> {
|
||||||
const SQL = await sqlEngine()
|
const SQL = await sqlEngine()
|
||||||
// 受信脚本的运行上限放宽,避免出题数据较大时误报;仍防子进程永久阻塞
|
options.onPhase?.("prepare")
|
||||||
const trustedLimitMs = Math.max(options.timeLimitMs * 5, 10_000)
|
// 受信脚本的运行上限放宽,避免出题数据较大时误报;仍防子进程永久阻塞。
|
||||||
|
// 三段受信执行(两次初始化 + 一次标准答案)共用同一个 deadline,
|
||||||
|
// 这样"受信阶段总耗时"有确定上限,父进程才能算出匹配的兜底时限。
|
||||||
|
const trustedDeadline = Date.now() + trustedBudgetMs(options.timeLimitMs)
|
||||||
|
|
||||||
let expected: unknown
|
let expected: unknown
|
||||||
const refDb = newDatabase(SQL, options.memoryLimitMb)
|
const refDb = newDatabase(SQL, options.memoryLimitMb)
|
||||||
try {
|
try {
|
||||||
executeTrusted(refDb, initSql, Date.now() + trustedLimitMs, "初始化脚本执行失败")
|
executeTrusted(refDb, initSql, trustedDeadline, "初始化脚本执行失败")
|
||||||
const last = executeTrusted(refDb, refSql, Date.now() + trustedLimitMs, "标准答案执行失败")
|
const last = executeTrusted(refDb, refSql, trustedDeadline, "标准答案执行失败")
|
||||||
if (options.mode === "query") {
|
if (options.mode === "query") {
|
||||||
expected = last
|
expected = last
|
||||||
} else {
|
} else {
|
||||||
@@ -317,10 +399,17 @@ export async function runCase(
|
|||||||
let actual: unknown
|
let actual: unknown
|
||||||
let elapsed = 0
|
let elapsed = 0
|
||||||
try {
|
try {
|
||||||
executeTrusted(studentDb, initSql, Date.now() + trustedLimitMs, "初始化脚本执行失败")
|
executeTrusted(studentDb, initSql, trustedDeadline, "初始化脚本执行失败")
|
||||||
|
options.onPhase?.("student")
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
try {
|
try {
|
||||||
actual = runStudent(studentDb, studentSql, options.mode, start + options.timeLimitMs)
|
actual = runStudent(
|
||||||
|
studentDb,
|
||||||
|
studentSql,
|
||||||
|
options.mode,
|
||||||
|
start + options.timeLimitMs,
|
||||||
|
options.memoryLimitMb,
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
elapsed = Date.now() - start
|
elapsed = Date.now() - start
|
||||||
const failure = error as SqlCaseError
|
const failure = error as SqlCaseError
|
||||||
@@ -399,7 +488,7 @@ export async function buildDisplay(
|
|||||||
) {
|
) {
|
||||||
const SQL = await sqlEngine()
|
const SQL = await sqlEngine()
|
||||||
const db = newDatabase(SQL, memoryLimitMb)
|
const db = newDatabase(SQL, memoryLimitMb)
|
||||||
const deadline = Date.now() + 10_000
|
const deadline = Date.now() + DISPLAY_BUDGET_MS
|
||||||
try {
|
try {
|
||||||
executeTrusted(db, initSql, deadline, "初始化脚本执行失败")
|
executeTrusted(db, initSql, deadline, "初始化脚本执行失败")
|
||||||
const tables = dumpDisplayTables(db)
|
const tables = dumpDisplayTables(db)
|
||||||
@@ -407,11 +496,7 @@ export async function buildDisplay(
|
|||||||
if (mode === "query") {
|
if (mode === "query") {
|
||||||
let expected: unknown = null
|
let expected: unknown = null
|
||||||
try {
|
try {
|
||||||
for (const statement of (db as unknown as {
|
for (const statement of iterate(db, refSql)) {
|
||||||
iterateStatements(sql: string): Iterable<{
|
|
||||||
step(): boolean; get(): unknown[]; getColumnNames(): string[]; free(): void
|
|
||||||
}>
|
|
||||||
}).iterateStatements(refSql)) {
|
|
||||||
try {
|
try {
|
||||||
const names = statement.getColumnNames()
|
const names = statement.getColumnNames()
|
||||||
if (names.length === 0) { while (statement.step()) { /* 无结果集 */ } ; continue }
|
if (names.length === 0) { while (statement.step()) { /* 无结果集 */ } ; continue }
|
||||||
@@ -443,7 +528,7 @@ export async function buildDisplay(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const before = dumpTables(db)
|
const before = dumpTables(db)
|
||||||
executeTrusted(db, refSql, Date.now() + 10_000, "标准答案执行失败")
|
executeTrusted(db, refSql, deadline, "标准答案执行失败")
|
||||||
const after = dumpTables(db)
|
const after = dumpTables(db)
|
||||||
const changed = new Set<string>()
|
const changed = new Set<string>()
|
||||||
for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { resolve } from "node:path"
|
import { resolve } from "node:path"
|
||||||
|
|
||||||
import { JudgeStatus, type JudgeStatusValue } from "../status"
|
import { JudgeStatus, type JudgeStatusValue } from "../status"
|
||||||
import type { CaseResult } from "./engine"
|
import { DISPLAY_BUDGET_MS, trustedBudgetMs, type CaseResult } from "./engine"
|
||||||
import type { SqlJob } from "./child"
|
import type { SqlJob } from "./child"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,12 +17,28 @@ import type { SqlJob } from "./child"
|
|||||||
* 实测 -v 之下 Bun 退出时有概率 panic(SIGILL)—— 结果早已写出但进程异常终止,
|
* 实测 -v 之下 Bun 退出时有概率 panic(SIGILL)—— 结果早已写出但进程异常终止,
|
||||||
* 父进程读到空串误判成超时,时好时坏。-d 限的是实际提交的内存(Linux 4.7 起
|
* 父进程读到空串误判成超时,时好时坏。-d 限的是实际提交的内存(Linux 4.7 起
|
||||||
* 也覆盖匿名 mmap),512MB 下正常判题稳定、`hex(zeroblob(2e8))` 被拦。
|
* 也覆盖匿名 mmap),512MB 下正常判题稳定、`hex(zeroblob(2e8))` 被拦。
|
||||||
|
*
|
||||||
|
* ## 兜底时限是分阶段的
|
||||||
|
*
|
||||||
|
* 单条 SQL 一旦进了 SQLite 的 step() 就打断不了(见 engine.ts 的说明),所以
|
||||||
|
* 跑飞的语句只能等这里的 SIGKILL。如果整个作业只给一个宽松的兜底时限,
|
||||||
|
* 1 秒时限的题也要等十几秒才判超时 —— 判题池就那么几个槽,一个学生提几发
|
||||||
|
* 死循环就能把所有人堵住。
|
||||||
|
*
|
||||||
|
* 所以子进程用 stderr 报阶段(`@phase:`),父进程边读边换表:
|
||||||
|
* 受信阶段(出题人的初始化脚本、标准答案)给足预算,一进学生 SQL 就把兜底
|
||||||
|
* 收到「题目时限 + 2s」。跑飞的学生语句最多多占 2 秒。
|
||||||
|
*
|
||||||
|
* 阶段还决定超时算谁的:卡在 prepare/display 是出题配置问题(SYSTEM_ERROR),
|
||||||
|
* 卡在 student 才是学生超时(TLE)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 子进程数据段上限(KB)。低于 512MB Bun 自己起不来 */
|
/** 子进程数据段上限(KB)。低于 512MB Bun 自己起不来 */
|
||||||
const CHILD_DATA_LIMIT_KB = 512 * 1024
|
const CHILD_DATA_LIMIT_KB = 512 * 1024
|
||||||
/** 父进程的兜底墙钟。比作业自报的时限宽裕,只负责杀掉真正跑飞的进程 */
|
/** 进程启动 + WASM 初始化 + JSON 收发的余量 */
|
||||||
const HARD_TIMEOUT_SLACK_MS = 15_000
|
const STARTUP_SLACK_MS = 3_000
|
||||||
|
/** 学生阶段的兜底余量:只用来覆盖单条语句无法打断这一段 */
|
||||||
|
const STUDENT_SLACK_MS = 2_000
|
||||||
|
|
||||||
export interface SqlJobFailure {
|
export interface SqlJobFailure {
|
||||||
ok: false
|
ok: false
|
||||||
@@ -32,7 +48,28 @@ export interface SqlJobFailure {
|
|||||||
|
|
||||||
type SqlJobOutcome<T> = { ok: true; value: T } | SqlJobFailure
|
type SqlJobOutcome<T> = { ok: true; value: T } | SqlJobFailure
|
||||||
|
|
||||||
async function runJob<T>(job: SqlJob, budgetMs: number): Promise<SqlJobOutcome<T>> {
|
interface JobBudget {
|
||||||
|
/** 受信阶段(初始化脚本 + 标准答案)合计预算 */
|
||||||
|
trustedMs: number
|
||||||
|
/** 学生 SQL 的预算;display 作业没有学生阶段,传 null */
|
||||||
|
studentMs: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const PHASE_FAILURE: Record<string, SqlJobFailure> = {
|
||||||
|
display: {
|
||||||
|
ok: false,
|
||||||
|
result: JudgeStatus.SYSTEM_ERROR,
|
||||||
|
message: "生成展示数据超时或内存超限,请检查初始化脚本与标准答案",
|
||||||
|
},
|
||||||
|
prepare: {
|
||||||
|
ok: false,
|
||||||
|
result: JudgeStatus.SYSTEM_ERROR,
|
||||||
|
message: "初始化脚本或标准答案超时/内存超限,请检查题目配置",
|
||||||
|
},
|
||||||
|
student: { ok: false, result: JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, message: "SQL 执行超时" },
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<T>> {
|
||||||
const entry = resolve(import.meta.dir, "child.ts")
|
const entry = resolve(import.meta.dir, "child.ts")
|
||||||
// 经 sh 起是为了用 ulimit —— Bun.spawn 没有直接设 rlimit 的接口
|
// 经 sh 起是为了用 ulimit —— Bun.spawn 没有直接设 rlimit 的接口
|
||||||
const child = Bun.spawn(
|
const child = Bun.spawn(
|
||||||
@@ -42,14 +79,32 @@ async function runJob<T>(job: SqlJob, budgetMs: number): Promise<SqlJobOutcome<T
|
|||||||
child.stdin.write(JSON.stringify(job))
|
child.stdin.write(JSON.stringify(job))
|
||||||
await child.stdin.end()
|
await child.stdin.end()
|
||||||
|
|
||||||
const timer = setTimeout(() => child.kill("SIGKILL"), budgetMs + HARD_TIMEOUT_SLACK_MS)
|
let timer = setTimeout(() => child.kill("SIGKILL"), budget.trustedMs + STARTUP_SLACK_MS)
|
||||||
|
let phase = ""
|
||||||
|
// stderr 要边读边看:阶段标记一到就得马上换兜底时限,攒到进程结束再读就没意义了
|
||||||
|
const readStderr = (async () => {
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
const reader = (child.stderr as ReadableStream<Uint8Array>).getReader()
|
||||||
|
let text = ""
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
text += decoder.decode(value, { stream: true })
|
||||||
|
const marks = text.match(/@phase:(\w+)/g)
|
||||||
|
const latest = marks?.[marks.length - 1]?.slice("@phase:".length)
|
||||||
|
if (latest && latest !== phase) {
|
||||||
|
phase = latest
|
||||||
|
if (phase === "student" && budget.studentMs !== null) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => child.kill("SIGKILL"), budget.studentMs + STUDENT_SLACK_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
let stdout = ""
|
let stdout = ""
|
||||||
let stderr = ""
|
|
||||||
try {
|
try {
|
||||||
;[stdout, stderr] = await Promise.all([
|
;[stdout] = await Promise.all([new Response(child.stdout).text(), readStderr])
|
||||||
new Response(child.stdout).text(),
|
|
||||||
new Response(child.stderr).text(),
|
|
||||||
])
|
|
||||||
await child.exited
|
await child.exited
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
@@ -57,12 +112,14 @@ async function runJob<T>(job: SqlJob, budgetMs: number): Promise<SqlJobOutcome<T
|
|||||||
|
|
||||||
if (!stdout.trim()) {
|
if (!stdout.trim()) {
|
||||||
// 子进程没来得及写结果就没了 —— 要么被我们 SIGKILL,要么被内核 OOM 掉。
|
// 子进程没来得及写结果就没了 —— 要么被我们 SIGKILL,要么被内核 OOM 掉。
|
||||||
// 用 stderr 里的阶段标记区分:卡在受信脚本是出题问题,卡在学生 SQL 是超时。
|
// 按最后一个阶段标记归因:卡在受信脚本是出题问题,卡在学生 SQL 才是超时。
|
||||||
const phase = stderr.includes("@phase:") ? stderr.split("@phase:")[1]?.split("\n")[0] : null
|
return (
|
||||||
if (phase === "display") {
|
PHASE_FAILURE[phase] ?? {
|
||||||
return { ok: false, result: JudgeStatus.SYSTEM_ERROR, message: "生成展示数据超时或内存超限,请检查初始化脚本与标准答案" }
|
ok: false,
|
||||||
}
|
result: JudgeStatus.SYSTEM_ERROR,
|
||||||
return { ok: false, result: JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, message: "SQL 执行超时" }
|
message: "SQL 判题子进程异常退出",
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -77,12 +134,15 @@ async function runJob<T>(job: SqlJob, budgetMs: number): Promise<SqlJobOutcome<T
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function runSqlCase(job: Extract<SqlJob, { kind: "judge" }>) {
|
export function runSqlCase(job: Extract<SqlJob, { kind: "judge" }>) {
|
||||||
return runJob<CaseResult>(job, Math.max(job.timeLimitMs * 5, 10_000))
|
return runJob<CaseResult>(job, {
|
||||||
|
trustedMs: trustedBudgetMs(job.timeLimitMs),
|
||||||
|
studentMs: job.timeLimitMs,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSqlDisplay(initSql: string, refSql: string, mode: "query" | "modify") {
|
export function buildSqlDisplay(initSql: string, refSql: string, mode: "query" | "modify") {
|
||||||
return runJob<{ tables: unknown[]; expected: unknown }>(
|
return runJob<{ tables: unknown[]; expected: unknown }>(
|
||||||
{ kind: "display", initSql, refSql, mode },
|
{ kind: "display", initSql, refSql, mode },
|
||||||
10_000,
|
{ trustedMs: DISPLAY_BUDGET_MS, studentMs: null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { resolve } from "node:path"
|
|||||||
import { count, desc, eq, gte, ilike, not, sql } from "drizzle-orm"
|
import { count, desc, eq, gte, ilike, not, sql } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
import { requireAdmin, requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
||||||
import { config } from "../../config"
|
import { config } from "../../config"
|
||||||
import { db, schema } from "../../db"
|
import { db, schema } from "../../db"
|
||||||
import { publishConfigUpdate } from "../../events"
|
import { publishConfigUpdate } from "../../events"
|
||||||
@@ -202,8 +202,12 @@ const MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
|||||||
* Simditor 富文本编辑器的图片上传。响应形状是编辑器约定的
|
* Simditor 富文本编辑器的图片上传。响应形状是编辑器约定的
|
||||||
* `{success, msg, filePath}`,不是本项目的 `{data}` 信封 —— 但外面仍然包一层 data,
|
* `{success, msg, filePath}`,不是本项目的 `{data}` 信封 —— 但外面仍然包一层 data,
|
||||||
* 由前端 api 层解包,这样它和其它接口共用同一个错误处理拦截器。
|
* 由前端 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 form = await c.req.formData().catch(() => null)
|
||||||
const image = form?.get("image")
|
const image = form?.get("image")
|
||||||
if (!(image instanceof File)) {
|
if (!(image instanceof File)) {
|
||||||
|
|||||||
@@ -189,7 +189,11 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
|
|||||||
title: original.contest.title,
|
title: original.contest.title,
|
||||||
description: original.contest.description,
|
description: original.contest.description,
|
||||||
tag: original.contest.tag,
|
tag: original.contest.tag,
|
||||||
password: original.contest.password,
|
// 不复制原比赛的密码。两个理由:一是克隆出来是一场新比赛、时间也是新的,
|
||||||
|
// 沿用旧密码意味着拿着旧密码的学生直接能进;二是本接口不校验归属
|
||||||
|
// (旧后端也不校验,教师可以拿别人的比赛做模板),复制过来就等于把别人的
|
||||||
|
// 比赛密码原样回传给调用者。克隆者自己重新设一个。
|
||||||
|
password: null,
|
||||||
// 克隆出来的一律不可见:时间是拍脑袋定的 10 分钟后,直接开放会让学生看到一场没准备好的赛
|
// 克隆出来的一律不可见:时间是拍脑袋定的 10 分钟后,直接开放会让学生看到一场没准备好的赛
|
||||||
visible: false,
|
visible: false,
|
||||||
allowedIpRanges: original.contest.allowedIpRanges,
|
allowedIpRanges: original.contest.allowedIpRanges,
|
||||||
|
|||||||
@@ -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)
|
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")
|
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) {
|
if (!problem.contestId || problem.isPublic) {
|
||||||
return failure(c, 409, "already-public", "Already be a public problem")
|
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) {
|
if (user.adminType !== "Super Admin" && contest.createdById !== user.id) {
|
||||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
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")
|
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)
|
const [duplicate] = await db.select({ id: schema.problem.id }).from(schema.problem)
|
||||||
|
|||||||
@@ -366,14 +366,20 @@ adminProblemSetRoutes.delete("/problem-sets/:id/badges/:badgeId", requireTeacher
|
|||||||
const row = await loadOwned(c, c.get("user")!)
|
const row = await loadOwned(c, c.get("user")!)
|
||||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
const badgeId = queryInteger(c.req.param("badgeId"), 0, { min: 1 })
|
const badgeId = queryInteger(c.req.param("badgeId"), 0, { min: 1 })
|
||||||
const deleted = await db.transaction(async (tx) => {
|
// 必须先确认这枚奖章确实属于本题单,再动 user_badge。
|
||||||
await tx.delete(schema.userBadge).where(eq(schema.userBadge.badgeId, badgeId))
|
// 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定题单,
|
||||||
return tx.delete(schema.problemsetBadge).where(and(
|
// 于是「自己的题单 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.id, badgeId),
|
||||||
eq(schema.problemsetBadge.problemsetId, row.id),
|
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)
|
return success(c, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,11 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- 卡点题目 / AC 趋势
|
// ---------------------------------------------------------------- 卡点题目 / 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 failedFilter = sql`filter (where ${inArray(schema.submission.result, FAILED)})`
|
||||||
const rows = await db.select({
|
const rows = await db.select({
|
||||||
displayId: schema.problem.displayId,
|
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()
|
const currentYear = new Date().getFullYear()
|
||||||
// 参数按旧后端的口径夹逼:越界一律回落到默认值,不报错
|
// 参数按旧后端的口径夹逼:越界一律回落到默认值,不报错
|
||||||
let sinceYear = queryInteger(c.req.query("sinceYear"), 2023)
|
let sinceYear = queryInteger(c.req.query("sinceYear"), 2023)
|
||||||
|
|||||||
@@ -724,7 +724,7 @@ export function removeUserFromProblemSet(problemSetId: number, userId: number) {
|
|||||||
|
|
||||||
// 学生卡点分析
|
// 学生卡点分析
|
||||||
export function getStuckProblems() {
|
export function getStuckProblems() {
|
||||||
return legacyResponse(api2.get("admin/problems/stuck"))
|
return legacyResponse(api2.get("admin/problem-analytics/stuck"))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopACTrend(params: {
|
export function getTopACTrend(params: {
|
||||||
@@ -733,7 +733,7 @@ export function getTopACTrend(params: {
|
|||||||
min_per_year: number
|
min_per_year: number
|
||||||
}) {
|
}) {
|
||||||
return legacyResponse(
|
return legacyResponse(
|
||||||
api2.get("admin/problems/ac-trend", {
|
api2.get("admin/problem-analytics/ac-trend", {
|
||||||
params: {
|
params: {
|
||||||
sinceYear: params.since_year,
|
sinceYear: params.since_year,
|
||||||
untilYear: params.until_year,
|
untilYear: params.until_year,
|
||||||
|
|||||||
476
docs/specs/phase4-review-authz.md
Normal file
476
docs/specs/phase4-review-authz.md
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
# 阶段 4 后台接口权限边界评审
|
||||||
|
|
||||||
|
评审对象:`apps/api/src/routes/admin/*.ts`(10 个文件、86 个 handler)+ 3 个挂在 `/admin` 之外但对应旧后端 admin 视图的端点。
|
||||||
|
参照物:`OnlineJudge/` 各 app 的 `views/admin.py`,装饰器定义在 `account/decorators.py`。
|
||||||
|
评审范围:**只看角色守卫与归属校验**,不评审风格 / 性能 / 可维护性。
|
||||||
|
|
||||||
|
- 评审人:独立安全评审
|
||||||
|
- 日期:2026-08-07
|
||||||
|
- 实跑环境:`http://localhost:3000`,postgres 5433 / redis 6380
|
||||||
|
- 实跑账号:`student`(id=2)、`e2etest`(id=4),测试期间临时提权,**结束后已全部还原为 Regular User / None**;造的 6 道题、3 场比赛、2 个题单、1 个奖章、1 个标签已全部删除,`problem` 表回到 20 行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **修复记录(2026-08-07,本文档之后)**
|
||||||
|
>
|
||||||
|
> C1、C2、I1、I2、I3、I4 **六条全部已修**,并用两个教师账号(互相不可见的题单/比赛)
|
||||||
|
> 实跑复验通过:跨题单删奖章 → 404 且对方奖章还在;跨租户 make-public → 404;
|
||||||
|
> from-public 拖别人的赛题 → 400 `not-a-public-problem`;克隆比赛回传 `password: null`;
|
||||||
|
> 分析接口移到 `/admin/problem-analytics/*` 后不再被 `/problems/:id` 遮蔽;
|
||||||
|
> upload-image 守卫收回 `requireAdmin`。
|
||||||
|
>
|
||||||
|
> Minor 四条未修:M1 已按「有意偏离」保留,M2/M3/M4 留待阶段 5。
|
||||||
|
|
||||||
|
## 结论速览
|
||||||
|
|
||||||
|
| 级别 | 数量 | 条目 |
|
||||||
|
|---|---|---|
|
||||||
|
| Critical | 2 | C1 跨题单删奖章会连带删掉别人的 user_badge;C2 make-public 无归属校验且回传完整题面 |
|
||||||
|
| Important | 4 | I1 两个 requireTeacher 端点被 `/problems/:id` 路由遮蔽;I2 from-public 不校验源题归属;I3 克隆比赛不校验归属且回传明文密码;I4 upload-image 守卫比旧后端严过头,教师/学生管理员写题面时会 403 |
|
||||||
|
| Minor | 4 | M1 visibility 的归属规则与 canEdit 不一致;M2 from-public 的错误码构成比赛存在性预言机;M3 三个 admin 端点的守卫写在 handler 里而非注册行;M4 禁用账号落到 401 而不是「账号已禁用」 |
|
||||||
|
|
||||||
|
守卫覆盖:**86 个后台 handler 全部挂了守卫,没有一个漏挂**。角色档位与旧后端装饰器逐条比对后全部一致(见文末对照表)。问题全部集中在**对象级归属校验**这一层。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical
|
||||||
|
|
||||||
|
### C1 — 跨题单删奖章:奖章保住了,但别人的 user_badge 被真删了
|
||||||
|
|
||||||
|
**文件**:`apps/api/src/routes/admin/problemset.ts:365-378`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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(
|
||||||
|
eq(schema.problemsetBadge.id, badgeId),
|
||||||
|
eq(schema.problemsetBadge.problemsetId, row.id), // ← 后校验
|
||||||
|
)).returning({ id: schema.problemsetBadge.id })
|
||||||
|
})
|
||||||
|
if (deleted.length === 0) return failure(c, 404, "badge-not-found", "奖章不存在")
|
||||||
|
```
|
||||||
|
|
||||||
|
父子归属校验放在了子表清理**之后**,而且 `if (deleted.length === 0)` 的 404 是在事务**提交之后**才返回的 —— 回调正常结束,事务照常 COMMIT。于是「奖章没删成」和「奖章的获得记录已经删光了」同时成立。
|
||||||
|
|
||||||
|
这正是任务里点名要查的场景:**A 题单的 id + B 题单的奖章 id**。
|
||||||
|
|
||||||
|
**旧后端对应行为**:`problemset/views/admin.py:287-301`,先 `ProblemSetBadge.objects.get(id=badge_id, problemset=problem_set)`,取不到直接 `return self.error("奖章不存在")`,`badge.delete()` 根本不会执行,user_badge 一行不动。
|
||||||
|
|
||||||
|
**实跑验证**
|
||||||
|
|
||||||
|
前置:`student`(A) 与 `e2etest`(B) 均为 Teacher Admin/Own。A 建题单 2,B 建题单 3 并在其下建奖章 2。手工插入两行 user_badge:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker exec oj2-postgres psql ... -tAc "insert into user_badge (user_id,badge_id,earned_time) values (4,2,now()),(2,2,now()); select id,user_id,badge_id from user_badge"
|
||||||
|
INSERT 0 2
|
||||||
|
2|4|2
|
||||||
|
3|2|2
|
||||||
|
```
|
||||||
|
|
||||||
|
A(只拥有题单 2)打 B 的奖章 2:
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/admin/problem-sets/2/badges/2 (cookie = A)
|
||||||
|
→ 404 {"error":{"code":"badge-not-found","message":"奖章不存在"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
看起来被挡住了。查库:
|
||||||
|
|
||||||
|
```
|
||||||
|
select count(*) from user_badge; → 0
|
||||||
|
select id,problemset_id,name from problemset_badge; → 2|3|REVIEW-B-BADGE
|
||||||
|
```
|
||||||
|
|
||||||
|
奖章还在,**两行 user_badge 全没了**。
|
||||||
|
|
||||||
|
对照:同一路由的 PUT 是安全的(校验和写在同一条 `and()` 里):
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/admin/problem-sets/2/badges/2 body={name:"HACKED",...} (cookie = A)
|
||||||
|
→ 404 badge-not-found
|
||||||
|
GET /api/admin/problem-sets/3/badges (cookie = B)
|
||||||
|
→ 200 [{"id":2,"name":"REVIEW-B-BADGE","conditionValue":1,...}] # 未被篡改
|
||||||
|
```
|
||||||
|
|
||||||
|
**风险场景**:教师 A 在自己题单页面点删除奖章时,前端只要拼错 badgeId(或 A 手工构造请求),就能把教师 B 班上全体学生已获得的奖章记录一次性抹掉,而 A 自己看到的是「奖章不存在」——**破坏是静默的,没有任何一方会收到提示**。奖章记录没有别处备份,`recalculateBadge` 只在改奖章时触发,B 不改奖章就永远恢复不了 earnedTime。
|
||||||
|
|
||||||
|
**修法**:把 `userBadge` 的清理条件收进子查询,或把归属校验提到事务最前面并在不匹配时 `throw` 触发回滚。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C2 — `make-public` 完全不校验归属,且把整份题面回传给越权者
|
||||||
|
|
||||||
|
**文件**:`apps/api/src/routes/admin/problem.ts:491-533`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
adminProblemRoutes.post("/problems/:id/make-public", requireProblemPermission, async (c) => {
|
||||||
|
...
|
||||||
|
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")
|
||||||
|
if (!problem.contestId || problem.isPublic) { ... }
|
||||||
|
// ↑ 这里之后直接开事务,没有 canEdit / ownedBy
|
||||||
|
...
|
||||||
|
return success(c, await serialize(created), 201) // ← 回传完整题面
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
同一文件里 `GET /problems/:id`(275)、`PUT`(323)、`DELETE`(371)、`/test-cases`(602)、`/sql-scripts`(628) 全都过了 `canEdit`,唯独这一条没有。
|
||||||
|
|
||||||
|
**旧后端对应行为**:`problem/views/admin.py:456-483` 的 `MakeContestProblemPublicAPIView` 同样没有 `ensure_created_by` —— **这个洞是从旧后端继承来的**。但旧后端第 483 行是 `return self.success()`,**不回传任何数据**;转出来的公开题 `created_by` 仍是原作者且 `visible=False`,越权者拿不到内容。新后端第 532 行 `return success(c, await serialize(created), 201)` 把 description、samples、testCaseId、**answers(标准答案)** 一起吐回去,**泄露面严格大于旧后端**。
|
||||||
|
|
||||||
|
**实跑验证**
|
||||||
|
|
||||||
|
A 建比赛 3(A 所有)并在其中建比赛题 38。B 直接读该题:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/problems/38 (cookie = B)
|
||||||
|
→ 404 {"error":{"code":"problem-not-found","message":"Problem does not exist"}} # 归属校验生效
|
||||||
|
```
|
||||||
|
|
||||||
|
B 换成 make-public:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/admin/problems/38/make-public body={"displayId":"STOLEN1"} (cookie = B)
|
||||||
|
→ 201 {"data":{"id":39,"_id":"STOLEN1","title":"REVIEW A","description":"<p>d</p>",
|
||||||
|
"inputDescription":"i","outputDescription":"o","samples":[{"input":"1","output":"1"}],
|
||||||
|
"testCaseId":"reviewdummy00000000000000000000",
|
||||||
|
"testCaseScore":[{"score":100,"input_name":"1.in","output_name":"1.out"}],
|
||||||
|
"hint":"","languages":["Python3"],"template":...
|
||||||
|
```
|
||||||
|
|
||||||
|
同一个人、同一道题,读接口 404、写接口 201 并把题面全给了。副作用还有两条:A 的比赛题被改成 `isPublic=true`(A 之后再想正常转公开会被 409 "Already be a public problem" 挡住),以及库里多出一道 A 名下的公开题。
|
||||||
|
|
||||||
|
**风险场景**:比赛开始前,任意持有 problem_permission 的管理员(含 Student Admin)遍历 problem id 就能把别人未开赛比赛的题面连同标准答案全部拖走。中职学校场景下这就是考前泄题。
|
||||||
|
|
||||||
|
**修法**:在 496 行之后补 `if (!(await canEdit(user, problem)))` → 404;顺带把 `problem.contestId` 分支的成功响应改成不回传题面(或至少 omit `answers`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
### I1 — `/problems/stuck` 与 `/problems/ac-trend` 被 `/problems/:id` 遮蔽,两个 requireTeacher 端点实际不可达
|
||||||
|
|
||||||
|
**文件**:`apps/api/src/routes/admin/tag.ts:190`、`tag.ts:215`(被 `apps/api/src/routes/admin/problem.ts:275` 遮蔽);成因在 `apps/api/src/routes/admin/index.ts:30-32`(`adminProblemRoutes` 先于 `adminTagRoutes` 注册)
|
||||||
|
|
||||||
|
Hono 按注册顺序匹配,`GET /problems/:id` 的 `:id` 会吃掉 `stuck` / `ac-trend` 这两个字面段。于是:
|
||||||
|
|
||||||
|
- 生效的守卫是 `requireProblemPermission`(problem.ts:275),**不是** tag.ts 上写的 `requireTeacher`
|
||||||
|
- handler 也是 problem.ts 的,`queryInteger("stuck", 0, {min:1})` 回落到 0,查不到题 → 404
|
||||||
|
- tag.ts:190 / 215 的两个 handler **一次都没被执行过**
|
||||||
|
|
||||||
|
**旧后端对应行为**:`problem/views/admin.py:638-640`(`StuckProblemsAPI.get` `@teacher_admin_required`)与 `675-677`(`TopACTrendAPI.get` `@teacher_admin_required`),两条独立 URL,正常可达。
|
||||||
|
|
||||||
|
**实跑验证**(用 problem_permission 做判别,因为两个守卫的档位不同)
|
||||||
|
|
||||||
|
`student` = Teacher Admin + `problem_permission='None'`。按 tag.ts 写的 `requireTeacher` 应当放行;按 problem.ts 的 `requireProblemPermission` 应当 403:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/problems/stuck → 403 {"error":{"code":"permission-denied","message":"权限不足"}}
|
||||||
|
GET /api/admin/problems/ac-trend → 403 {"error":{"code":"permission-denied","message":"权限不足"}}
|
||||||
|
GET /api/admin/contests → 200 {"data":{"results":[],"total":0}} # requireTeacher 确实放行
|
||||||
|
```
|
||||||
|
|
||||||
|
判定:跑的是 `requireProblemPermission`,即 problem.ts:275。
|
||||||
|
|
||||||
|
把权限补回 `Own`、再提到 Super Admin,两条依旧是 problem.ts 的错误体:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/problems/stuck (Teacher/Own) → 404 {"code":"problem-not-found","message":"Problem does not exist"}
|
||||||
|
GET /api/admin/problems/ac-trend (Teacher/Own) → 404 {"code":"problem-not-found","message":"Problem does not exist"}
|
||||||
|
GET /api/admin/problems/stuck (Super Admin) → 404 {"code":"problem-not-found","message":"Problem does not exist"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**风险场景**:当前只是「两个教师功能死了 + 挂的守卫不是实际生效的守卫」。真正的隐患在于**注册行上写的守卫和实际执行的守卫不一致**——`index.ts:15-21` 的注释明确说「不在总入口兜一层,就是为了让守卫从注册行上看得出来」,这条遮蔽正好打破了那个前提。将来若有人放宽 `GET /problems/:id`,会连带把两个 teacher-only 的统计接口一起放宽,而 tag.ts 上的 `requireTeacher` 看着还是对的。
|
||||||
|
|
||||||
|
**修法**:把 tag.ts 的两条静态路由挪到 problem.ts 的 `/problems/:id` 之前注册(调整 index.ts 顺序,或把它们改成 `/problem-stats/stuck` 这类不冲突的路径)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I2 — `from-public` 只校验目标比赛归属,不校验源题归属,可把别人的比赛题拖进自己的比赛
|
||||||
|
|
||||||
|
**文件**:`apps/api/src/routes/admin/problem.ts:535-580`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [contest] = await db.select().from(schema.contest).where(eq(schema.contest.id, contestId)).limit(1)
|
||||||
|
const [problem] = await db.select().from(schema.problem)
|
||||||
|
.where(eq(schema.problem.id, parsed.data.problemId)).limit(1) // ← 任意 id,无过滤
|
||||||
|
if (!contest || !problem) return failure(c, 404, "not-found", ...)
|
||||||
|
if (user.adminType !== "Super Admin" && contest.createdById !== user.id) { // ← 只校验比赛
|
||||||
|
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
源题既不校验 `createdById`,也不校验 `visible`,更不限制 `contestId is null`(尽管路由叫 from-**public**)。拷贝出来的题落在调用者自己的比赛里,之后 `canEdit` 走「比赛题看比赛创建者」分支 → 调用者对它有完全读写权。
|
||||||
|
|
||||||
|
**旧后端对应行为**:`problem/views/admin.py:486-511` 的 `AddContestProblemAPI.post` —— **一个装饰器都没有,也没有任何 ensure_created_by**,比新后端还宽(连管理员身份都不要求)。新后端补上了 `requireProblemPermission` + 比赛归属,是净收紧;但对象级的源题归属仍然缺失。
|
||||||
|
|
||||||
|
**实跑验证**
|
||||||
|
|
||||||
|
A 的公开题 37(A 所有)、A 的比赛题 38(在 A 的比赛 3 里)。B 建自己的比赛 5:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/admin/contests/5/problems/from-public {"problemId":37,"displayId":"X1"} (cookie = B)
|
||||||
|
→ 201 {"data":{"id":40,"_id":"X1","title":"REVIEW REVIEWA1","description":"<p>d</p>",...}}
|
||||||
|
|
||||||
|
POST /api/admin/contests/5/problems/from-public {"problemId":38,"displayId":"X2"} (cookie = B)
|
||||||
|
→ 201 {"data":{"id":41,"_id":"X2","title":"REVIEW A","description":"<p>d</p>",
|
||||||
|
"testCaseId":"reviewdummy00000000000000000000",...}}
|
||||||
|
|
||||||
|
GET /api/admin/problems/41 (cookie = B)
|
||||||
|
→ 200 {"data":{"id":41,"_id":"X2","title":"REVIEW A",...}} # 复制品 B 完全可读可改
|
||||||
|
```
|
||||||
|
|
||||||
|
问题 38 是 A 的**比赛题**,B 直读它是 404(见 C2 实跑),经这条路径拷一份就拿到了。
|
||||||
|
|
||||||
|
**风险场景**:与 C2 同源 —— 考前拖走别人比赛的题面与标准答案,只是入口换成了「往自己比赛里加题」。
|
||||||
|
|
||||||
|
**修法**:源题至少要求 `contestId is null`(名副其实的 public);理想情况再叠一层 `canEdit(user, problem) || problem.visible`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I3 — 克隆比赛不校验归属,且响应里回传原比赛的明文密码
|
||||||
|
|
||||||
|
**文件**:`apps/api/src/routes/admin/contest.ts:172-233`(`password: original.contest.password` 在 192 行,`serialize` 下发 `password` 在 49 行)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
|
||||||
|
const [original] = await selectContest(id)
|
||||||
|
// 克隆不要求 ownedBy:旧后端这里也没有 ensure_created_by,教师可以拿别人的比赛做模板。
|
||||||
|
// 克隆出来的归调用者所有、且默认不可见,所以不构成越权修改。
|
||||||
|
if (!original) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||||
|
```
|
||||||
|
|
||||||
|
注释对「不构成越权**修改**」的判断是对的,但漏了越权**读取**:克隆会把原比赛的全部题目连同 `password` 一起复制进调用者名下的新比赛,调用者随后对这些题有完整读写权。
|
||||||
|
|
||||||
|
**旧后端对应行为**:`contest/views/admin.py:265-301` 的 `ContestCloneAPI.post`,`@teacher_admin_required`,确实没有 `ensure_created_by`,也照样 `password=original.password`。**行为等价**;但旧的 `ContestAdminSerializer` 同样下发 password,所以泄露面一致,属于原样继承的洞。
|
||||||
|
|
||||||
|
**实跑验证**
|
||||||
|
|
||||||
|
A 建密码保护赛 3(password=`secret123`,visible=true)。B 直读被拦:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/contests/3 (cookie = B) → 404 contest-not-found
|
||||||
|
PUT /api/admin/contests/3 (cookie = B) → 404 contest-not-found
|
||||||
|
GET /api/admin/contests/3/problems (cookie = B) → 404 contest-not-found
|
||||||
|
GET /api/admin/contests/3/acm-helper (cookie=B) → 404 contest-not-found
|
||||||
|
```
|
||||||
|
|
||||||
|
B 克隆:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/admin/contests/3/clone (cookie = B)
|
||||||
|
→ 201 {"data":{"id":4,"title":"REVIEW-CONTEST-A","description":"d","tag":"t",
|
||||||
|
"startTime":"2026-08-07 23:19:29.475+00","endTime":"2026-08-08 00:19:29.475+00",
|
||||||
|
...,"password":"secret123"
|
||||||
|
```
|
||||||
|
|
||||||
|
四条读接口全 404,克隆一次全拿到,外加明文密码。
|
||||||
|
|
||||||
|
**风险场景**:任一 Teacher Admin 可在开赛前克隆同事的比赛,读全部题面,并拿到密码直接以学生身份进原赛。
|
||||||
|
|
||||||
|
**修法**:至少不要把 `password` 复制到克隆件、也不要在克隆响应里回传;更彻底的做法是给 clone 加 `ownedBy`(会改变旧行为,需要产品确认「拿别人比赛当模板」是不是真需求)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I4 — `POST /upload-image` 收成 Super Admin,教师/学生管理员写题面时会 403
|
||||||
|
|
||||||
|
**文件**:`apps/api/src/routes/admin/conf.ts:206`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
adminConfRoutes.post("/upload-image", requireSuperAdmin, async (c) => {
|
||||||
|
```
|
||||||
|
|
||||||
|
**旧后端对应行为**:`utils/views.py:13-16` 的 `SimditorImageUploadAPIView.post` —— **完全没有装饰器**(`utils/urls.py:6` 挂在 `api/admin/upload_image`),任何人包括匿名都能传图。新后端收成 Super Admin 是必要的修补,但收过头了。
|
||||||
|
|
||||||
|
前端 `ojnext/src/admin/api.ts:159-163` 的 `uploadImage` 被 `MarkdownEditor.vue` / `TextEditor.vue` 调用,而这两个编辑器出现在:
|
||||||
|
|
||||||
|
- `src/admin/problem/detail.vue` —— 题目编辑页,`requireProblemPermission` 档位,Student Admin 就能进
|
||||||
|
- `src/admin/contest/detail.vue` —— 比赛编辑页,`requireTeacher` 档位
|
||||||
|
- `src/admin/announcement/detail.vue`、`src/admin/tutorial/detail.vue` —— 这两个确实是 Super Admin
|
||||||
|
|
||||||
|
**实跑验证**(`e2etest` = Teacher Admin/Own)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/admin/upload-image (cookie = Teacher Admin)
|
||||||
|
→ 403 {"error":{"code":"permission-denied","message":"权限不足"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**风险场景**:不是越权,是**可用性回归**——教师往比赛描述里插图、学生管理员往题面里插图,都会静默失败。这类问题在权限评审里要一起提,因为「修法」是调守卫档位。
|
||||||
|
|
||||||
|
**修法**:改成 `requireAdmin`(三种管理员均可)。上传目录是服务端生成文件名、限了后缀和 10MB,风险可控。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Minor
|
||||||
|
|
||||||
|
### M1 — `PUT /problems/:id/visibility` 的归属规则与 `canEdit` 不一致,比赛题上会反转
|
||||||
|
|
||||||
|
**文件**:`apps/api/src/routes/admin/tag.ts:173-186`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (!canManageAllProblems(user) && problem.createdById !== user.id) {
|
||||||
|
return failure(c, 404, "problem-not-found", "题目不存在")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
用的是**公开题**的规则(看 `problem.createdById`),而 `problem.ts:44-52` 的 `canEdit` 对比赛题走的是「看比赛创建者」。结果在比赛题上两套规则给出相反答案。
|
||||||
|
|
||||||
|
**旧后端对应行为**:`problem/views/admin.py:600-610` 的 `ProblemVisibleAPI.put` **完全没有归属校验**(而且 605-607 的 `self.error` 漏写 return,题不存在会 500)。新后端补了校验,是净收紧;这里记的是新后端内部两套规则不自洽。
|
||||||
|
|
||||||
|
**实跑验证**
|
||||||
|
|
||||||
|
题 41 位于 B 的比赛 5 里,但 `created_by_id = 2`(A,from-public 复制时继承的):
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/admin/problems/41/visibility (cookie = B,比赛所有者) → 404 {"code":"problem-not-found","message":"题目不存在"}
|
||||||
|
PUT /api/admin/problems/41/visibility (cookie = A,非比赛所有者) → 200 {"data":{"visible":false}}
|
||||||
|
GET /api/admin/problems/41 (cookie = A) → 404 # canEdit 说 A 无权
|
||||||
|
GET /api/admin/problems/41 (cookie = B) → 200 # canEdit 说 B 有权
|
||||||
|
```
|
||||||
|
|
||||||
|
同一道题,`canEdit` 判 B 能改、A 不能;`visibility` 判 A 能改、B 不能。A 能把 B 比赛里的题隐藏掉,B 自己反而改不回来。
|
||||||
|
|
||||||
|
**修法**:`visibility` 改用 `canEdit`。
|
||||||
|
|
||||||
|
### M2 — `from-public` 的错误码构成比赛存在性预言机
|
||||||
|
|
||||||
|
**文件**:`apps/api/src/routes/admin/problem.ts:544` vs `546-548`
|
||||||
|
|
||||||
|
比赛不存在返回 `not-found`,比赛存在但不属于你返回 `contest-not-found`。攻击者带一个已知有效的 `problemId`,就能靠错误码区分「这个 contestId 不存在」和「存在但是别人的」。其余所有跨租户路径都统一成了同一个码(已实跑确认:contest 系列全是 `contest-not-found`、problemset 系列全是 `problem-set-not-found`、problem 系列全是 `problem-not-found`),只有这一处破例。
|
||||||
|
|
||||||
|
**旧后端对应行为**:`problem/views/admin.py:493-494` 统一返回 `"Contest or Problem does not exist"`,不区分。
|
||||||
|
|
||||||
|
(仅静态判断 + 上文 I2 实跑观察到的错误码差异,未专门构造预言机实验。)
|
||||||
|
|
||||||
|
### M3 — 三个 admin 端点的守卫写在 handler 体内,不在注册行
|
||||||
|
|
||||||
|
**文件**:`apps/api/src/routes/submission.ts:221-224`、`submission.ts:337-340`、`apps/api/src/routes/flowchart.ts:142-145`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
submissionRoutes.get("/submissions/statistics", requireAuth, async (c) => {
|
||||||
|
if (!isTeacherOrAbove(c.get("user"))) return failure(c, 403, "permission-denied", ...)
|
||||||
|
submissionRoutes.post("/submissions/:id/rejudge", requireAuth, async (c) => {
|
||||||
|
if (!isSuperAdmin(c.get("user"))) return failure(c, 403, "permission-denied", ...)
|
||||||
|
flowchartRoutes.get("/flowcharts/statistics", requireAuth, async (c) => {
|
||||||
|
if (!isTeacherOrAbove(c.get("user"))) return failure(c, 403, "permission-denied", ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
**档位本身正确**,与 `submission/views/admin.py:14`(`@super_admin_required`)、`submission/views/admin.py:31`(`@teacher_admin_required`)、`flowchart/views/admin.py:69`(`@teacher_admin_required`)逐条对应。问题只是写法违背了 `routes/admin/index.ts:15-21` 立下的约定(「守卫要从注册行上看得出来」),下一个人加同类端点时容易漏掉那个 if。建议改用 `requireTeacher` / `requireSuperAdmin` 中间件。
|
||||||
|
|
||||||
|
(仅静态判断,未实跑 —— 这三条不在阶段 4 的 admin 目录里。)
|
||||||
|
|
||||||
|
### M4 — 禁用账号落到 401 `login-required`,与旧后端的「账号已禁用」不同
|
||||||
|
|
||||||
|
**文件**:`apps/api/src/auth/middleware.ts:32-34`(注释)、`41`
|
||||||
|
|
||||||
|
`getSessionUser` 对禁用用户返回 null → 401 `login-required`。旧 `account/decorators.py:36-37` 是先过权限检查再报 `permission-denied` + 「账号已禁用」。
|
||||||
|
|
||||||
|
方向上更安全(提前一步拦),但前端拦截器对 `login-required` 是弹登录框:一个会话中途被禁用的用户会陷入「弹登录框 → 登录 → 又被弹」的循环,而不是看到「账号已禁用」。属于可用性 / 可诊断性问题,不是越权。
|
||||||
|
|
||||||
|
(仅静态判断。)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 有意偏离的独立判断
|
||||||
|
|
||||||
|
| 偏离 | 判断 | 依据 |
|
||||||
|
|---|---|---|
|
||||||
|
| 删除不存在的记录返回 404 而非静默成功 | **合理** | 旧 `announcement/views/admin.py:57-61`、`tutorial/views/admin.py:62-66`、`achievement/views/admin.py:72-78` 都是 `filter().delete()` 静默成功,后台点了删除却没删掉是真的会误导人。这些资源全在 Super Admin 档位,404 不构成枚举风险。**唯一例外是奖章删除**——那里的 404 和真实副作用不一致,见 C1,问题不在 404 本身而在事务顺序。 |
|
||||||
|
| 题单后台列表不按 visible 过滤 | **合理,且是必要修复** | 旧 `problemset/views/admin.py:35` 写死 `filter(visible=True)`,同时 `ProblemSetVisibleAPI`(356-372) 又提供切换可见性——设成不可见后题单从后台列表消失,界面上再也改不回来。新写法仍保留 `createdById` 过滤(`problemset.ts:80`),越权面没有变化。已实跑确认 A 看不到 B 的题单(`GET /problem-sets` 返回 `{"results":[],"total":0}`)。 |
|
||||||
|
| ACM 核查校验 rank 归属 | **合理,且修掉了旧后端一个真洞** | 旧 `contest/views/admin.py:205` 只按 `pk=data["rank_id"]` 取,任一 Teacher Admin 带任意 rank_id 就能改别的比赛的核查标记。新 `contest.ts:298-302` 用 `and(id, contestId)` 收口,父子归属正确。(静态判断 + `GET /contests/3/acm-helper` 的跨租户 404 实跑,PUT 分支因需要真实 ACM 排名数据未实跑。) |
|
||||||
|
| 题目删除统一成一条路由 | **合理** | 旧的两条(`ProblemAPI.delete` 332 行 `contest_id__isnull=True` + `ContestProblemAPI.delete` 443 行 `contest_id__isnull=False`)只是按 contestId 分流,比赛是从题目推导出来的,前端根本不需要传 contestId。新 `problem.ts:371-379` 的 `canEdit` 已经把两种归属规则都覆盖了:公开题看题目创建者、比赛题看比赛创建者,与旧的 `ensure_created_by(problem)` / `ensure_created_by(problem.contest)` 一一对应。附带把「有提交记录不能删」从只对比赛题生效(旧 447 行)推广到公开题,是收紧不是放松。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核实过、确认没问题的部分
|
||||||
|
|
||||||
|
这一节界定覆盖面,与上面的问题清单同等重要。
|
||||||
|
|
||||||
|
**角色守卫档位(86/86 逐条比对,全部一致,无一漏挂)**
|
||||||
|
|
||||||
|
| 新后端路由组 | 守卫 | 旧后端装饰器 | 结论 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/users*`(6 条) | requireSuperAdmin | `account/views/admin.py:43,76,125,159,244` `@super_admin_required` | 一致 |
|
||||||
|
| `/announcements*`(5 条) | requireSuperAdmin | `announcement/views/admin.py:13,23,40,57` | 一致 |
|
||||||
|
| `/tutorials*` `/exercises*`(10 条) | requireSuperAdmin | `tutorial/views/admin.py:17,27,44,62,70,90,106,119,127` | 一致 |
|
||||||
|
| `/achievements*` `/achievement-metrics`(6 条) | requireSuperAdmin | `achievement/views/admin.py:10,21,43,72,82` | 一致 |
|
||||||
|
| `/website` `/judge-servers*` `/orphan-test-cases*` `/dashboard` `/random-usernames`(9 条) | requireSuperAdmin | `conf/views.py:49,66,76,84,148,162`;`DashboardInfoAPI`(187) 与 `RandomUsernameAPI`(216) 旧后端**无装饰器** | 一致或**收紧**(后两条旧后端任何人可读,含班级用户名枚举) |
|
||||||
|
| `/contests*`(5 条) | requireTeacher | `contest/views/admin.py:33,53,78,267` `@teacher_admin_required` | 一致 |
|
||||||
|
| `/contests/:id/acm-helper`(2 条) | requireTeacher | `contest/views/admin.py:167,200` | 一致 |
|
||||||
|
| `/problem-sets*`(15 条) | requireTeacher | `problemset/views/admin.py` 全部 `@teacher_admin_required` | 一致 |
|
||||||
|
| `/ai/reports*`(3 条) | requireTeacher | `ai/views/admin.py:9,31` `@teacher_admin_required` | 一致 |
|
||||||
|
| `/problems/stuck` `/problems/ac-trend` | requireTeacher(写了但被遮蔽) | `problem/views/admin.py:639,676` | 档位对,见 I1 |
|
||||||
|
| `/problems*` `/contests/:id/problems*` `/test-cases` `/sql-test-cases/*`(14 条) | requireProblemPermission | `problem/views/admin.py:237,259,294,326,458,757,798`;`TestCaseAPI`(143)、`ContestProblemAPI`(343) 旧后端**无装饰器**、`AddContestProblemAPI`(486) 亦无 | 一致或**收紧** |
|
||||||
|
| `/problem-tags*` `/problems/batch-tag` `/problems/:id/visibility` `/problems/flowchart`(5 条) | requireProblemPermission | `problem/views/admin.py:515,524,554,570,601,614` | 一致 |
|
||||||
|
|
||||||
|
**中间件语义**(`auth/middleware.ts:48-67`):`ADMIN_ROLES` / `TEACHER_ROLES` 白名单与 `account/models.py:65-73` 的 `is_admin_role()` / `is_teacher_or_above()` 逐项一致;`requireProblemPermission` = 管理员 ∧ `problemPermission !== "None"`,与 `decorators.py:78-84` 一致。已实跑确认 Teacher Admin + `problem_permission='None'` 在 `/problems/stuck` 上得到 403,在 `/contests` 上得到 200。
|
||||||
|
|
||||||
|
**跨租户读写隔离(实跑,全部 404、不泄露存在性)**
|
||||||
|
|
||||||
|
```
|
||||||
|
(cookie = B, 全部指向 A 的资源)
|
||||||
|
GET /api/admin/problem-sets/2 → 404 problem-set-not-found
|
||||||
|
GET /api/admin/problem-sets/2/badges → 404 problem-set-not-found
|
||||||
|
DELETE /api/admin/problem-sets/3/progress/4 → 404 problem-set-not-found (A 打 B)
|
||||||
|
PUT /api/admin/problem-sets/2/problems/1 → 404 problem-not-in-set (跨题单 itemId)
|
||||||
|
GET /api/admin/contests/3 → 404 contest-not-found
|
||||||
|
PUT /api/admin/contests/3 → 404 contest-not-found
|
||||||
|
GET /api/admin/contests/3/problems → 404 contest-not-found
|
||||||
|
GET /api/admin/contests/3/acm-helper → 404 contest-not-found
|
||||||
|
GET /api/admin/problems/37 → 404 problem-not-found
|
||||||
|
DELETE /api/admin/problems/37 → 404 problem-not-found
|
||||||
|
GET /api/admin/problems/37/test-cases → 404 problem-not-found
|
||||||
|
PUT /api/admin/problems/37/visibility → 404 problem-not-found
|
||||||
|
GET /api/admin/problems → 200 {"results":[],"total":0} # 列表按 createdById 过滤
|
||||||
|
POST /api/admin/problems/batch-tag {problemIds:[40]} → 404 no-problems # 非本人题目被过滤掉
|
||||||
|
```
|
||||||
|
|
||||||
|
错误消息统一是「不存在」,没有出现「无权限」。**归属校验的正向行为是对的,问题只出在 C1/C2/I2 三条例外路径上。**
|
||||||
|
|
||||||
|
**超管旁路(实跑)**:`student` 提为 Super Admin 后,`GET /problem-sets/3`、`GET /contests/5`、`GET /problems/41` 全部 200,`ownedBy` / `canEdit` 的超管分支正确。
|
||||||
|
|
||||||
|
**其他确认无误的点**
|
||||||
|
|
||||||
|
- `canEdit`(`problem.ts:44-52`)对「比赛题看比赛创建者、公开题看题目创建者」的区分实现正确,且比赛题分支**不**给 `problemPermission=All` 旁路 —— 与旧 `ensure_created_by` 对非 Problem 实例只认 `created_by`/超管的语义一致
|
||||||
|
- `PUT /problem-sets/:id/problems/:itemId`、`DELETE /problem-sets/:id/problems/:itemId`、`PUT /problem-sets/:id/badges/:badgeId`、`DELETE /problem-sets/:id/progress/:userId` 的父子归属都写在同一条 `and()` 里,无 TOCTOU,已实跑确认(**只有 badges 的 DELETE 例外,见 C1**)
|
||||||
|
- `DELETE /problem-tags/:id`(`tag.ts:92-102`)的事务里先删 `problemTags` 再删 `problemTag`,但删的是同一个 id,标签不存在时前一句是空操作,**不存在 C1 那种连带破坏**
|
||||||
|
- `DELETE /users`(`account.ts:237-257`)禁止删除自己,已实跑确认返回 400 `cannot-delete-self`
|
||||||
|
- `PUT /users/:id` 的 `normalizePermission`(`account.ts:49-53`)与 `account/views/admin.py:98-105` 的归一逻辑一致,降级超管会同步清掉 All
|
||||||
|
- 真名下发受控:`sampleUser`(`routes/helpers.ts:15-25`)默认 `realName: null`,只有 `acm-helper`(`contest.ts:272`)和题单进度(`problemset.ts:426`)两处显式下发,两处都在 requireTeacher + 归属校验之后
|
||||||
|
- `GET /ai/reports/:id`(`ai.ts:70-83`)不下发 `data` / `systemPrompt` / `userPrompt`
|
||||||
|
- `GET /judge-servers`(`conf.ts:90-100`)下发 judge token,但在 requireSuperAdmin 之后,与旧 `conf/views.py:66-74` 一致
|
||||||
|
- `DELETE /orphan-test-cases`(`conf.ts:147-160`)对指定 id 也先确认是孤儿,比旧 `conf/views.py:162-171` 严 —— 合理收紧
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 覆盖率
|
||||||
|
|
||||||
|
| 项 | 数 |
|
||||||
|
|---|---|
|
||||||
|
| `routes/admin/*.ts` 中的 handler | 86 |
|
||||||
|
| 逐条静态比对旧后端装饰器的 handler | 86(100%) |
|
||||||
|
| `/admin` 之外但对应旧 admin 视图的 handler | 3(submission statistics / rejudge、flowchart statistics) |
|
||||||
|
| **合计评审端点** | **89** |
|
||||||
|
| 实跑验证的端点 | **46**(52%) |
|
||||||
|
| 实跑覆盖的角色组合 | Regular User(隐含匿名 401)、Student Admin+Own、Teacher Admin+None、Teacher Admin+Own、Super Admin+All |
|
||||||
|
| 实跑的跨租户攻击路径 | 18 条(14 条被正确拦截、4 条成功越权) |
|
||||||
|
|
||||||
|
未实跑的 43 条主要是:只在 Super Admin 档位、无归属概念的 CRUD(tutorials/exercises/achievements/announcements 的写操作),以及需要真实测试点文件或真实 ACM 排名数据的端点(`/test-cases` 上传、`/sql-scripts`、`/sql-test-cases/*`、`acm-helper` 的 PUT、`/problems/flowchart` 需要 AI 服务)。这些的守卫档位已逐条静态核对,均与旧后端一致。
|
||||||
|
|
||||||
|
## 环境还原确认
|
||||||
|
|
||||||
|
```
|
||||||
|
select 'problem',count(*) from problem → 20 (与评审开始时一致)
|
||||||
|
select 'contest',count(*) from contest → 0
|
||||||
|
select 'problemset',count(*) from problemset → 0
|
||||||
|
select 'badge',count(*) from problemset_badge → 0
|
||||||
|
select 'user_badge',count(*) from user_badge → 0
|
||||||
|
select count(*) from problem_tag where id=95 → 0 (测试标签已删)
|
||||||
|
|
||||||
|
id|username|admin_type |problem_permission
|
||||||
|
1 |devadmin|Super Admin |All
|
||||||
|
2 |student |Regular User |None
|
||||||
|
4 |e2etest |Regular User |None
|
||||||
|
```
|
||||||
|
|
||||||
|
`e2etest` 的密码在测试中被 reset-password 接口改掉,已通过 `PUT /users/4` 改回 `Test123456` 并重新登录验证成功;`user_profile.real_name` 也已还原为 NULL。未执行任何 drop / truncate。
|
||||||
130
docs/specs/phase4-review-sql-sandbox.md
Normal file
130
docs/specs/phase4-review-sql-sandbox.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# Phase 4 安全评审 — SQL 判题引擎沙箱逃逸与资源耗尽
|
||||||
|
|
||||||
|
评审对象:`apps/api/src/judge/sql/`(engine.ts / child.ts / index.ts)+ 判题接线 `judge/run.ts:judgeSqlSubmission` + 测试点处理 `services/test-case.ts`。
|
||||||
|
参照旧实现:`OnlineJudge/judge/sql_runner.py` / `sql_dispatcher.py`(Python sqlite3 + authorizer / progress_handler / setlimit)。
|
||||||
|
|
||||||
|
**评审方式**:全部实跑。快速迭代喂 `child.ts` JSON 作业;关键结论走完整提交链路(建 SQL 题 → 提交 → 判题结果)与父进程 `index.ts` 逻辑。测试环境已还原(题目/提交/测试点目录已删,`student` 已改回 Regular User / None,无残留进程)。
|
||||||
|
|
||||||
|
> **修复记录(2026-08-07,本文档之后)**
|
||||||
|
>
|
||||||
|
> - **I-1 已修**:`runStudent` 加逐语句守卫,学生 SQL 里的 PRAGMA 一律拒(两种题型都拒,
|
||||||
|
> 对齐旧实现 authorizer 的 `_DENIED_ALWAYS`)。关键字判定用 `sqlite3_normalized_sql`,
|
||||||
|
> 注释/大小写/空白由 SQLite 自己抹平。另加兜底:每条语句前重放 `query_only` 与
|
||||||
|
> `max_page_count`,即便判定漏了也关不掉只读。**M-1 的 `max_page_count` 可调大部分一并修掉。**
|
||||||
|
> - **I-2 已修**:子进程用 stderr 报阶段(`prepare` / `student` / `display`),父进程边读边换
|
||||||
|
> 兜底时限 —— 一进学生 SQL 就收到「题目时限 + 2s」。1s 限的题跑飞语句实测 **26s → 3.06s**。
|
||||||
|
> 顺带修正归因:卡在受信脚本(出题人的初始化/标准答案)现在报 SYSTEM_ERROR,不再甩给学生 TLE。
|
||||||
|
> - **未修**:M-1 的「固定 512MB 与题目 `memoryLimit` 脱钩」、M-2、M-3。
|
||||||
|
> - `engine.ts` 头部的防护对照表已按实测重写,两处削弱写在正文里。
|
||||||
|
> 另记:stock sql.js 的 wasm **没有导出** `sqlite3_progress_handler` / `sqlite3_interrupt` /
|
||||||
|
> `sqlite3_set_authorizer` / `sqlite3_limit`(已核对导出表),要用得自己编 wasm。
|
||||||
|
|
||||||
|
## 结论速览
|
||||||
|
|
||||||
|
| # | 威胁 | 结果 | 级别 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 文件系统逃逸(ATTACH 等) | **打不穿**。WASM 无宿主 FS 绑定,结构性隔离,比旧实现更强 | — |
|
||||||
|
| 2 | 超时挂住判题进程 | 单条长语句引擎拦不到,只靠父进程 25s 后 SIGKILL;1s 限的题实测 26s 才 TLE,25x 放大 + 判题池仅 2 并发 → 可拒绝服务 | **Important** |
|
||||||
|
| 3 | 内存耗尽 | 单值/瞬时内存被固定 512MB `ulimit -d` 兜住(已验证生效),但与题目 `memoryLimit` 脱钩,且 `max_page_count` 学生可自行调大 | **Minor** |
|
||||||
|
| 4 | 查询题只读绕过 | **破防**。`PRAGMA query_only=0` 一句即可恢复写权限,实测查询题用 DML 拿到 Accepted | **Important** |
|
||||||
|
| 5 | 硬编码常量作弊 | 多测试点防线成立(前提:测试点数据确实不同,代码只强制"≥2 个"不强制"数据不同") | **Minor** |
|
||||||
|
| 6 | 出题人侧预览/生成 | 与学生同隔离,无宿主 FS,比旧实现更强;教师可让预览请求挂 25s | **Minor** |
|
||||||
|
|
||||||
|
对照表逐条核对结果(实现者声明 vs 实测):
|
||||||
|
|
||||||
|
| 旧防护 | 新做法 | 核对结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| authorizer 禁 ATTACH | WASM 无宿主 FS,结构上够不到 | ✅ **成立且更强** |
|
||||||
|
| authorizer 白名单让查询题只读 | `PRAGMA query_only=1` | ❌ **不成立**:学生可 `PRAGMA query_only=0` 反手关掉 |
|
||||||
|
| progress_handler 墙钟超时 | 子进程外部 SIGKILL | ⚠️ **部分成立**:只在父进程路径生效且慢 25x;单条长语句引擎内拦不到 |
|
||||||
|
| `setlimit(LIMIT_LENGTH)` | 子进程 `ulimit -d` | ⚠️ **部分成立**:`-d` 确实作用到最终进程,但固定 512MB、与题目内存限脱钩 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Important
|
||||||
|
|
||||||
|
### I-1 查询题只读可被 `PRAGMA query_only=0` 一句绕过
|
||||||
|
|
||||||
|
- **位置**:`apps/api/src/judge/sql/engine.ts:199-200`(`if (mode === "query") db.run("PRAGMA query_only=1")`),学生 SQL 随后在 `engine.ts:202 → executeStatements` 无任何语句级过滤地执行。
|
||||||
|
- **根因**:新实现放弃了 authorizer,学生 SQL 的 PRAGMA 完全不设防。`query_only` 只是一个可写开关,学生自己就能把它关回去。旧实现把 `SQLITE_PRAGMA` 放进 `_DENIED_ALWAYS`,学生连一条 PRAGMA 都跑不了(`sql_runner.py` `_run_student` authorizer)。
|
||||||
|
- **攻击手法**:查询题提交 `PRAGMA query_only=0; <任意 DML/DDL>; <正确 SELECT>`。
|
||||||
|
- **实测(完整提交链路,query 题 id=43,标准答案 `SELECT a FROM t ORDER BY a`)**:
|
||||||
|
|
||||||
|
攻击提交:
|
||||||
|
```sql
|
||||||
|
PRAGMA query_only=0; UPDATE t SET a=a; SELECT a FROM t ORDER BY a;
|
||||||
|
```
|
||||||
|
→ `RESULT 0`(**ACCEPTED**)—— UPDATE 成功执行,只读约束失效。
|
||||||
|
|
||||||
|
对照提交(不带 PRAGMA):
|
||||||
|
```sql
|
||||||
|
UPDATE t SET a=a; SELECT a FROM t ORDER BY a;
|
||||||
|
```
|
||||||
|
→ `RESULT 4`(RUNTIME_ERROR)`err_info: 本题为查询题,禁止修改数据或表结构…`
|
||||||
|
|
||||||
|
child.ts 层复现同样区分:带 PRAGMA 的 `DELETE/INSERT` 走到 WA(写入生效),不带的报 readonly。
|
||||||
|
- **旧实现表现**:`PRAGMA query_only=0` 会被 authorizer 直接 DENY(`not authorized`),学生无法改动只读态;且白名单模式下任何非 SELECT 授权码都拒绝。
|
||||||
|
- **实际危害边界**:每个测试点都是独立的内存库、无宿主 FS、跨测试点不持久,因此**不构成沙箱逃逸,也不构成作弊**(学生仍需对所有测试点产出正确结果集,DML 帮不上)。危害限于:查询题的"只读"教学约束被破坏,学生可用增删改而非查询"混"过题目。但它直接证伪了对照表第 2 行的安全声明,且利用成本为零,故列 Important。
|
||||||
|
- **修复方向**:学生 SQL 执行前用 `iterateStatements` 预扫,遇到 PRAGMA(或至少 `query_only` / `max_page_count` / `writable_schema` 等敏感 PRAGMA)即拒;或在只读模式下用不可翻转的手段(如整库以 immutable/只读方式打开)替代可写开关。
|
||||||
|
|
||||||
|
### I-2 单条长语句超时只能靠父进程 25s 后 SIGKILL,放大 25x 且可拖垮判题池
|
||||||
|
|
||||||
|
- **位置**:`engine.ts:116-149`(`executeStatements` 的 deadline 检查在 **`engine.ts:126`**,只在语句之间触发);`index.ts:45`(`setTimeout(kill, budgetMs + HARD_TIMEOUT_SLACK_MS)`);`index.ts:80`(`runSqlCase` budget = `max(timeLimitMs*5, 10_000)`);`index.ts:25`(`HARD_TIMEOUT_SLACK_MS = 15_000`)。
|
||||||
|
- **根因**:引擎的墙钟检查只发生在 `iterateStatements` 的每条语句开头。**一条长时间运行的语句(递归 CTE 死循环、大 CROSS JOIN 喂聚合)在单次 `step()` 内部执行,`step()` 期间不检查 deadline**,引擎无法中断。旧实现的 `progress_handler` 每 1000 条 VM 指令回调一次,在语句执行**过程中**就能按墙钟中断,约 `timeLimitMs`(1s)即杀。
|
||||||
|
- **攻击手法**:`WITH RECURSIVE r(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM r) SELECT count(*) FROM r;`(聚合,不向外层吐行,`ROW_LIMIT` 也拦不到)。
|
||||||
|
- **实测**:
|
||||||
|
- 直喂 child.ts(无父进程):`timeout 12` 强杀,**子进程 12s 内从不自终**(引擎 deadline 对单语句无效)。
|
||||||
|
- 走父进程 `index.ts` `runSqlCase`(timeLimitMs=1000):`elapsed_ms=25005`,返回 `{"ok":false,"result":1,"message":"SQL 执行超时"}`。
|
||||||
|
- **完整提交链路**(1s 限的 query 题):从提交到出 `RESULT 1`(TLE)实测 **26 秒**。
|
||||||
|
- **判题池影响**:`config.ts:68` `judgeConcurrency` 默认 **2**;SQL 判题作业与所有语言共用同一 BullMQ 判题 worker。用户级限流 `capacity=20`(`throttling.ts:24`)。单个学生一次性提交 20 条死循环 SQL ≈ `20×25s / 2 = 250s`,**两个判题槽被占满约 4 分钟**,期间全体学生(含 Python/C)提交排队。考试/比赛期这是可用性风险。
|
||||||
|
- **旧实现表现**:`progress_handler` 在 ~1s(`timeLimitMs`)即中断,worker 几乎立刻释放。新实现慢 25 倍且完全依赖父进程(子进程独立运行时永不自终)。
|
||||||
|
- **修复方向**:把 `HARD_TIMEOUT_SLACK_MS` 从 15s 收紧、`runSqlCase` budget 别乘 5;或给 sql.js 编译进 `sqlite3_progress_handler` 等价的中断回调,在语句内按墙钟中断。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Minor
|
||||||
|
|
||||||
|
### M-1 学生内存受固定 512MB `ulimit -d` 约束,与题目 `memoryLimit` 脱钩;`max_page_count` 学生可调大
|
||||||
|
|
||||||
|
- **位置**:`index.ts:23`(`CHILD_DATA_LIMIT_KB = 512 * 1024`,**硬编码**,不读题目 `memoryLimit`);`index.ts:38-41`(`sh -c "ulimit -d …; exec …"`);`engine.ts:101-108`(`newDatabase`:`max_page_count = memoryLimitMb*256`)。
|
||||||
|
- **核对"`ulimit -d` 是否真作用到最终 bun 进程"(重点关注项)**:**成立**。通过同款 `sh -c "ulimit -d 524288; exec bun …"` 包装起子进程后读 `/proc/self/limits`:
|
||||||
|
```
|
||||||
|
Max data size 536870912 536870912 bytes
|
||||||
|
```
|
||||||
|
`exec` 保留 rlimit,`-d` 确实落到最终 bun 进程。`hex(zeroblob(3e8))` 实测被拦,返回 `RESULT 3`(MEMORY_LIMIT_EXCEEDED,"单个数据值超出内存限制"),完整链路复现一致。所以 `-d` 而非 `-v` 的选型与"作用到最终进程"的声明都成立。
|
||||||
|
- **不足**:
|
||||||
|
1. 512MB 是**全局固定值**,与题目 `memoryLimit`(如 64MB)无关。旧实现 `setlimit(SQLITE_LIMIT_LENGTH, memory_limit_mb*1MB)` 把单值上限贴着题目内存限(64MB)。新实现下 64MB 的题,学生单值/瞬时内存可到 ~512MB 才报 MLE(8x)。
|
||||||
|
2. 题目内存限唯一的落点 `max_page_count` 是一条 **PRAGMA,学生可自行 `PRAGMA max_page_count=1000000` 调大**(同 I-1 根因:PRAGMA 不设防)。实测该 PRAGMA 被接受、无 authorizer 拒绝。因此题目 `memoryLimit` 对学生**不是权威约束**,真正的硬顶只有那 512MB。
|
||||||
|
- **危害边界**:512MB 仍能兜住机器(单进程封顶、`ROW_LIMIT=10000` 兜结果集行数),不至于拖垮宿主;只是"每题内存限"名不副实。故 Minor。
|
||||||
|
|
||||||
|
### M-2 测试点只强制"≥2 个",不强制"数据不同";作弊防线依赖出题人
|
||||||
|
|
||||||
|
- **位置**:`services/test-case.ts:102-105`(`if (options.sql && selected.length < 2) throw …`)。
|
||||||
|
- **核对作弊假设(题目要求至少 2 个数据不同的测试点,硬编码常量过不了)**:**假设成立,但仅在测试点数据确实不同的前提下**。
|
||||||
|
- 实测(TC1 数据 `1,2,3`,TC2 数据 `10,20,30,40`):提交硬编码 `SELECT 1 UNION SELECT 2 UNION SELECT 3;` → `RESULT -1`(WRONG_ANSWER)。多测试点防线有效。
|
||||||
|
- 但代码只校验 `.sql` 文件个数 ≥2,**不校验两个测试点跑出的期望结果是否不同**。题目页展示的是**测试点 1** 的期望结果(`buildDisplay` 取 test case 1,`admin/problem.ts:160-173`),学生能看到 TC1 的答案。若出题人不慎让 TC2 的数据产出与 TC1 相同的结果集,硬编码即可 AC。
|
||||||
|
- **旧实现表现**:同样基于"跑标准答案 + 多测试点比对",无"数据必须不同"的强校验,行为一致(非回归)。故 Minor,属出题人操作风险。
|
||||||
|
|
||||||
|
### M-3 出题人侧预览可挂 25s(教师自伤,有限)
|
||||||
|
|
||||||
|
- **位置**:`admin/problem.ts:653`(`/sql-test-cases/preview` → `buildSqlDisplay`),走同一子进程隔离,budget 10s + slack 15s。
|
||||||
|
- **实测**:教师 `refSql` 放死循环递归 CTE → 预览 HTTP 请求阻塞 **25005ms** 后返回 400 `生成展示数据超时或内存超限`。占用一个 HTTP 处理 25s。教师半可信、且限于自身请求,Minor。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 试过但没打穿(覆盖面界定)
|
||||||
|
|
||||||
|
- **ATTACH 写宿主文件**(query & modify & 受信 init 三种上下文):`ATTACH DATABASE '/tmp/…/evil.db' AS evil; CREATE TABLE evil.x…` → `unable to open database`,宿主上**无文件生成**。WASM(sql.js)默认 MEMFS,无 NODEFS 绑定,结构性够不到宿主 FS。
|
||||||
|
- **ATTACH 读已存在的宿主 sqlite 文件**(modify 模式,目标是真实 `secret.db`):同样 `unable to open database`,读不到。
|
||||||
|
- **受信脚本(出题人 init)里的 ATTACH**:预览接口同样 `unable to open database`。旧实现靠 `_trusted_authorizer` 拒 ATTACH 且跑在有宿主 FS 的 Django 进程里;新实现结构性隔离,**更强**。
|
||||||
|
- **超大单值撑爆内存**:`hex(zeroblob(3e8))` → 被 512MB `ulimit -d` / WASM 堆拦,MEMORY_LIMIT_EXCEEDED,未 OOM 宿主。
|
||||||
|
- **结果集撑爆**:`ROW_LIMIT=10000` 在 `step()` 循环内计数,超限即 MLE(`engine.ts:136-138`)。
|
||||||
|
- **硬编码常量答案**(跨数据不同的 2 测试点):WRONG_ANSWER。
|
||||||
|
- **多条语句的循环/超时**:语句之间的 `Date.now() > deadline`(`engine.ts:126`)能在 ~timeLimit 处以 `interrupted` → TLE 拦下(仅**单条**长语句拦不到,见 I-2)。
|
||||||
|
- **子进程 `finish()` 的 SIGKILL 自杀导致结果丢失/截断**:未复现。`child.ts:47-51` 用 `writeSync(1,…)` 循环写满全部字节再 `SIGKILL`;父进程 `index.ts:49-52` 并发 `new Response(child.stdout).text()` 持续排空管道。判题结果 payload 仅数百字节,全部实测被完整解析。理论残余风险:若 stdout payload 超过管道缓冲(64KB)且父进程未及时排空、fd 1 为非阻塞,`writeSync` 可能 EAGAIN 抛错丢输出——但当前 payload 体量下不触发,且父进程始终并发排空,判定低风险。设计中"跳过 Bun teardown 避免 `ulimit` 下 SIGILL panic 误判超时"的推理合理。
|
||||||
|
- **空 stdout → 超时误判**:父进程 `index.ts:58-66` 在子进程被 SIGKILL(stdout 空)时按 `@phase` 标记区分:卡在 display 报 SYSTEM_ERROR,卡在 judge 报 CPU_TIME_LIMIT_EXCEEDED。递归 CTE 死循环实测正确落到 TLE。
|
||||||
|
|
||||||
|
## 备注
|
||||||
|
|
||||||
|
- 未发现真正的沙箱逃逸(文件/进程/宿主)或能让错误答案判 Accepted 的作弊路径。最接近"严重"的是 I-2 的判题池拒绝服务(可用性),其次 I-1 的只读破防(安全控制失效但危害受限)。
|
||||||
|
- 环境已还原:题目 id=43 及其 5 条提交、`problem_tags` 关联、两个测试点目录(`d6sps…`、`v3h9…`)已删;`data/test_case/` 仅剩原有 `79343208b704f6e75b4b6d885285280e`;`student` 已改回 Regular User / None;无残留 `child.ts`/攻击进程。未改动任何 OJ2 / OnlineJudge / ojnext 源码(两旧仓 `git status` 干净)。
|
||||||
Reference in New Issue
Block a user