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:
@@ -8,10 +8,10 @@ import {
|
|||||||
problemDetailSchema,
|
problemDetailSchema,
|
||||||
problemListItemSchema,
|
problemListItemSchema,
|
||||||
} from "@oj2/contract"
|
} 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 { Hono } from "hono"
|
||||||
|
|
||||||
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
|
import { optionalAuth, requireAuth } from "../auth/middleware"
|
||||||
import { setContestPassword } from "../auth/session"
|
import { setContestPassword } from "../auth/session"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { failure, success } from "../http"
|
import { failure, success } from "../http"
|
||||||
@@ -22,10 +22,12 @@ import {
|
|||||||
contestStatus,
|
contestStatus,
|
||||||
findVisibleContest,
|
findVisibleContest,
|
||||||
isContestAdmin,
|
isContestAdmin,
|
||||||
|
requireContestAccess,
|
||||||
|
type ContestEnv,
|
||||||
} from "../services/contest"
|
} from "../services/contest"
|
||||||
import { objectValue, publicTemplates, queryInteger, sampleUser, stringArray } from "./helpers"
|
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) {
|
async function creator(id: number) {
|
||||||
const [row] = await db.select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName })
|
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
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
contestRoutes.get("/contests/:id/problems", optionalAuth, async (c) => {
|
contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess("problems"), async (c) => {
|
||||||
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
const contest = c.get("contest")!
|
||||||
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)
|
|
||||||
const rows = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
|
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))
|
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, 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) => {
|
contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireContestAccess("problems"), async (c) => {
|
||||||
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
const contest = c.get("contest")!
|
||||||
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)
|
|
||||||
const [row] = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
|
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))
|
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, 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) => {
|
contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("ranks"), async (c) => {
|
||||||
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
const contest = c.get("contest")!
|
||||||
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)
|
|
||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
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))
|
const where = and(eq(schema.acmContestRank.contestId, contest.id), inArray(schema.user.adminType, ["Regular User", "Student Admin"]), eq(schema.user.isDisabled, false))
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { and, count, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"
|
import { and, count, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
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 type { AuthUser } from "../auth/session"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { failure, success } from "../http"
|
import { failure, success } from "../http"
|
||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
findVisibleContest,
|
findVisibleContest,
|
||||||
ipAllowed,
|
ipAllowed,
|
||||||
isContestAdmin,
|
isContestAdmin,
|
||||||
|
requireContestAccess,
|
||||||
|
type ContestEnv,
|
||||||
} from "../services/contest"
|
} from "../services/contest"
|
||||||
import { CodeFormatError, formatCode } from "../services/format-code"
|
import { CodeFormatError, formatCode } from "../services/format-code"
|
||||||
import { getBooleanOption } from "../services/options"
|
import { getBooleanOption } from "../services/options"
|
||||||
@@ -40,7 +42,7 @@ import {
|
|||||||
todayStart,
|
todayStart,
|
||||||
} from "./helpers"
|
} from "./helpers"
|
||||||
|
|
||||||
export const submissionRoutes = new Hono<AppEnv>()
|
export const submissionRoutes = new Hono<ContestEnv>()
|
||||||
|
|
||||||
function stringArray(value: unknown): string[] {
|
function stringArray(value: unknown): string[] {
|
||||||
return Array.isArray(value)
|
return Array.isArray(value)
|
||||||
@@ -68,6 +70,8 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
|
|||||||
}
|
}
|
||||||
let contestId: number | null = null
|
let contestId: number | null = null
|
||||||
if (parsed.data.contestId) {
|
if (parsed.data.contestId) {
|
||||||
|
// 这里用不了 requireContestAccess 中间件:比赛 id 来自请求体,
|
||||||
|
// 中间件跑的时候 body 还没解析。全仓只有这一处仍是手工调用,改动时留意别漏掉鉴权。
|
||||||
const contest = await findVisibleContest(parsed.data.contestId)
|
const contest = await findVisibleContest(parsed.data.contestId)
|
||||||
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||||
const access = await canAccessContest(c, contest, "problems")
|
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) => {
|
submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireContestAccess("submissions", "contestId"), async (c) => {
|
||||||
const contest = await findVisibleContest(queryInteger(c.req.param("contestId"), 0, { min: 1 }))
|
const contest = c.get("contest")!
|
||||||
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)
|
|
||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
const filters = [eq(schema.submission.contestId, contest.id)]
|
const filters = [eq(schema.submission.contestId, contest.id)]
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
import { createHash } from "node:crypto"
|
import { createHash } from "node:crypto"
|
||||||
|
|
||||||
import { and, eq } from "drizzle-orm"
|
import { and, eq } from "drizzle-orm"
|
||||||
import type { Context } from "hono"
|
import type { Context, MiddlewareHandler } from "hono"
|
||||||
|
|
||||||
import type { AppEnv } from "../auth/middleware"
|
import type { AppEnv } from "../auth/middleware"
|
||||||
import type { AuthUser } from "../auth/session"
|
import type { AuthUser } from "../auth/session"
|
||||||
import { getContestPassword } from "../auth/session"
|
import { getContestPassword } from "../auth/session"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
|
import { failure } from "../http"
|
||||||
|
|
||||||
export type ContestRow = typeof schema.contest.$inferSelect
|
export type ContestRow = typeof schema.contest.$inferSelect
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 走过 requireContestAccess 的路由,可以从 c.var.contest 直接拿到已鉴权的比赛。
|
||||||
|
*
|
||||||
|
* 类型上是可选的(同一个 router 里还有不涉及比赛的路由),所以 handler 里要写 `!`。
|
||||||
|
* 万一漏挂中间件,这里会在运行时抛错变成 500 —— 吵闹但安全,
|
||||||
|
* 而漏调 canAccessContest 是静默放行,两者不可同日而语。
|
||||||
|
*/
|
||||||
|
export interface ContestEnv extends AppEnv {
|
||||||
|
Variables: AppEnv["Variables"] & { contest?: ContestRow }
|
||||||
|
}
|
||||||
|
|
||||||
export function contestStatus(contest: ContestRow) {
|
export function contestStatus(contest: ContestRow) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (Date.parse(contest.startTime) > now) return "1" as const
|
if (Date.parse(contest.startTime) > now) return "1" as const
|
||||||
@@ -42,8 +54,10 @@ export async function findVisibleContest(id: number) {
|
|||||||
return contest ?? null
|
return contest ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function canAccessContest(
|
// 泛型而不是写死 Context<AppEnv>:requireContestAccess 传进来的是 Context<ContestEnv>,
|
||||||
c: Context<AppEnv>,
|
// 它比 AppEnv 多一个变量,而 Hono 的 Context 在 Variables 上是逆变的,写死会类型不兼容。
|
||||||
|
export async function canAccessContest<E extends AppEnv>(
|
||||||
|
c: Context<E>,
|
||||||
contest: ContestRow,
|
contest: ContestRow,
|
||||||
checkType: "details" | "problems" | "ranks" | "submissions",
|
checkType: "details" | "problems" | "ranks" | "submissions",
|
||||||
) {
|
) {
|
||||||
@@ -62,6 +76,34 @@ export async function canAccessContest(
|
|||||||
return { ok: true as const }
|
return { ok: true as const }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比赛内容路由的守卫中间件。旧后端用 `@check_contest_permission` 装饰器,漏挂一眼看得出来;
|
||||||
|
* 手工在 handler 里调 `canAccessContest` 则漏调一次就是静默放行,而且这类路由挂的是
|
||||||
|
* `optionalAuth`(本身不拦人),从路由注册那一行完全看不出它受保护。这个中间件把
|
||||||
|
* 「取比赛 → 404 → 鉴权 → 401/403」四步收进注册行里,恢复旧后端那种显眼程度。
|
||||||
|
*
|
||||||
|
* 通过后比赛对象放进 `c.var.contest`,handler 直接取,不必再查一次库。
|
||||||
|
*
|
||||||
|
* 注意:`POST /submissions` 用不了它 —— 那里的比赛 id 来自请求体而非路径参数,
|
||||||
|
* 中间件跑的时候还没解析 body。那一处仍是手工调用,见 submission.ts 内的说明。
|
||||||
|
*/
|
||||||
|
export function requireContestAccess(
|
||||||
|
checkType: "details" | "problems" | "ranks" | "submissions",
|
||||||
|
paramName = "id",
|
||||||
|
): MiddlewareHandler<ContestEnv> {
|
||||||
|
return async (c, next) => {
|
||||||
|
const id = Number(c.req.param(paramName))
|
||||||
|
const contest = Number.isInteger(id) && id > 0 ? await findVisibleContest(id) : null
|
||||||
|
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||||
|
const access = await canAccessContest(c, contest, checkType)
|
||||||
|
if (!access.ok) {
|
||||||
|
return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
|
||||||
|
}
|
||||||
|
c.set("contest", contest)
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ipv4Number(value: string) {
|
function ipv4Number(value: string) {
|
||||||
const parts = value.split(".").map(Number)
|
const parts = value.split(".").map(Number)
|
||||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null
|
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null
|
||||||
|
|||||||
Reference in New Issue
Block a user