fix(Minor M2): 比赛权限加中间件兜底

旧后端用 @check_contest_permission 装饰器,漏挂一眼看得出来;新后端手工在 handler
里调 canAccessContest,漏调一次就是静默放行,而且这类路由挂的是 optionalAuth
(本身不拦人),从路由注册那一行完全看不出它受保护。

新增 requireContestAccess(checkType, paramName),把「取比赛 → 404 → 鉴权 →
401/403」四步收进注册行。手工调用点 5 → 2:

- GET /contests/:id/access —— 报告权限而非强制,不能 403,留手工
- POST /submissions —— 比赛 id 来自请求体,中间件跑时 body 还没解析,留手工

两处都就地写了说明。canAccessContest 改成泛型,因为 Hono 的 Context 在
Variables 上逆变,写死 Context<AppEnv> 与 ContestEnv 不兼容。

实测拦截行为不变:匿名 401、登录 200、密码赛未过密码 403。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 07:00:58 -06:00
parent 97c54d38b5
commit 20ecc05f63
3 changed files with 64 additions and 28 deletions

View File

@@ -8,10 +8,10 @@ import {
problemDetailSchema,
problemListItemSchema,
} from "@oj2/contract"
import { and, asc, count, desc, eq, gte, ilike, inArray, isNull, lte, sql } from "drizzle-orm"
import { and, asc, count, desc, eq, gte, ilike, inArray, lte, sql } from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
import { optionalAuth, requireAuth } from "../auth/middleware"
import { setContestPassword } from "../auth/session"
import { db, schema } from "../db"
import { failure, success } from "../http"
@@ -22,10 +22,12 @@ import {
contestStatus,
findVisibleContest,
isContestAdmin,
requireContestAccess,
type ContestEnv,
} from "../services/contest"
import { objectValue, publicTemplates, queryInteger, sampleUser, stringArray } from "./helpers"
export const contestRoutes = new Hono<AppEnv>()
export const contestRoutes = new Hono<ContestEnv>()
async function creator(id: number) {
const [row] = await db.select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName })
@@ -110,11 +112,8 @@ async function contestProblemTags(problemIds: number[]) {
return map
}
contestRoutes.get("/contests/:id/problems", optionalAuth, async (c) => {
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, "problems")
if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess("problems"), async (c) => {
const contest = c.get("contest")!
const rows = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
@@ -138,11 +137,8 @@ contestRoutes.get("/contests/:id/problems", optionalAuth, async (c) => {
})))
})
contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, async (c) => {
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, "problems")
if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireContestAccess("problems"), async (c) => {
const contest = c.get("contest")!
const [row] = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
@@ -187,11 +183,8 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, async (c) =
}))
})
contestRoutes.get("/contests/:id/rank", optionalAuth, async (c) => {
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, "ranks")
if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("ranks"), async (c) => {
const contest = c.get("contest")!
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const where = and(eq(schema.acmContestRank.contestId, contest.id), inArray(schema.user.adminType, ["Regular User", "Student Admin"]), eq(schema.user.isDisabled, false))

View File

@@ -14,7 +14,7 @@ import {
import { and, count, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
import { optionalAuth, requireAuth } from "../auth/middleware"
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db"
import { failure, success } from "../http"
@@ -26,6 +26,8 @@ import {
findVisibleContest,
ipAllowed,
isContestAdmin,
requireContestAccess,
type ContestEnv,
} from "../services/contest"
import { CodeFormatError, formatCode } from "../services/format-code"
import { getBooleanOption } from "../services/options"
@@ -40,7 +42,7 @@ import {
todayStart,
} from "./helpers"
export const submissionRoutes = new Hono<AppEnv>()
export const submissionRoutes = new Hono<ContestEnv>()
function stringArray(value: unknown): string[] {
return Array.isArray(value)
@@ -68,6 +70,8 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
}
let contestId: number | null = null
if (parsed.data.contestId) {
// 这里用不了 requireContestAccess 中间件:比赛 id 来自请求体,
// 中间件跑的时候 body 还没解析。全仓只有这一处仍是手工调用,改动时留意别漏掉鉴权。
const contest = await findVisibleContest(parsed.data.contestId)
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, "problems")
@@ -462,11 +466,8 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
}))
})
submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, async (c) => {
const contest = await findVisibleContest(queryInteger(c.req.param("contestId"), 0, { min: 1 }))
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, "submissions")
if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireContestAccess("submissions", "contestId"), async (c) => {
const contest = c.get("contest")!
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const filters = [eq(schema.submission.contestId, contest.id)]