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

@@ -1,15 +1,27 @@
import { createHash } from "node:crypto"
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 { AuthUser } from "../auth/session"
import { getContestPassword } from "../auth/session"
import { db, schema } from "../db"
import { failure } from "../http"
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) {
const now = Date.now()
if (Date.parse(contest.startTime) > now) return "1" as const
@@ -42,8 +54,10 @@ export async function findVisibleContest(id: number) {
return contest ?? null
}
export async function canAccessContest(
c: Context<AppEnv>,
// 泛型而不是写死 Context<AppEnv>requireContestAccess 传进来的是 Context<ContestEnv>
// 它比 AppEnv 多一个变量,而 Hono 的 Context 在 Variables 上是逆变的,写死会类型不兼容。
export async function canAccessContest<E extends AppEnv>(
c: Context<E>,
contest: ContestRow,
checkType: "details" | "problems" | "ranks" | "submissions",
) {
@@ -62,6 +76,34 @@ export async function canAccessContest(
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) {
const parts = value.split(".").map(Number)
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null