diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 038111e2..b53e8aa6 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -6,6 +6,7 @@ import { logServerError } from "@/lib/server-log"; import { validateCsrfToken } from "@/lib/foundation/security"; const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); +const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB type AdminContext = NonNullable>>; type RouteContext = { params?: Promise> }; @@ -15,7 +16,7 @@ type AdminHandler = ( routeContext: RouteContext, ) => Promise | Response; -export function withAdmin(options: { permission?: string; requireCsrf?: boolean }, handler: AdminHandler) { +export function withAdmin(options: { permission?: string; requireCsrf?: boolean; maxBodyBytes?: number }, handler: AdminHandler) { return async (request: NextRequest, routeContext: RouteContext = {}) => { if (options.requireCsrf === true && MUTATING_METHODS.has(request.method)) { const csrfToken = request.headers.get("x-csrf-token") ?? request.headers.get("csrf-token") ?? ""; @@ -25,6 +26,14 @@ export function withAdmin(options: { permission?: string; requireCsrf?: boolean } } + if (MUTATING_METHODS.has(request.method)) { + const contentLength = request.headers.get("content-length"); + const maxBytes = options.maxBodyBytes ?? MAX_BODY_BYTES; + if (contentLength && Number(contentLength) > maxBytes) { + return NextResponse.json({ ok: false, error: `Request body exceeds ${maxBytes} bytes` }, { status: 413 }); + } + } + const context = await getApiAdminContext(); if (!context) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); if ( diff --git a/src/lib/foundation/types.ts b/src/lib/foundation/types.ts index c657406e..8be47bd2 100644 --- a/src/lib/foundation/types.ts +++ b/src/lib/foundation/types.ts @@ -31,7 +31,7 @@ export interface AdminActionContext extends ActionContext { export interface ActionSuccess> { ok: true; - data: T; + data?: T; } export interface ActionFailure { ok: false; diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index a664e8c6..2d8ec066 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -1,18 +1,11 @@ import { headers } from "next/headers"; import { redis } from "@/lib/redis"; -/** - * Fixed-window rate limiter with optional Redis backend. Falls back to in-process - * Map when Redis is unavailable or unconfigured — fine for single-server deployments. - * - * Periodic cleanup runs every 5 minutes to keep the in-process map bounded. - */ type Bucket = { count: number; resetAt: number }; const buckets = new Map(); export interface RateLimitResult { ok: boolean; - /** Seconds until the window resets (0 when allowed). */ retryAfter: number; } @@ -25,17 +18,15 @@ function cleanup(): void { const now = Date.now(); if (now - lastCleanup < CLEANUP_INTERVAL_MS) return; lastCleanup = now; - if (buckets.size <= MAX_BUCKETS) { - for (const [k, b] of buckets) if (now >= b.resetAt) buckets.delete(k); - } else { - for (const [k, b] of buckets) if (now >= b.resetAt) buckets.delete(k); - if (buckets.size > MAX_BUCKETS) { - const sorted = [...buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt); - const toRemove = Math.floor(sorted.length * 0.2); - for (let i = 0; i < toRemove; i++) - // eslint-disable-next-line security/detect-object-injection -- numeric array index - buckets.delete(sorted[i][0]); - } + + for (const [k, b] of buckets) { + if (now >= b.resetAt) buckets.delete(k); + } + + if (buckets.size > MAX_BUCKETS) { + const sorted = [...buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt); + const keysToRemove = sorted.slice(0, Math.floor(sorted.length * 0.2)).map((entry) => entry[0]); + for (const key of keysToRemove) buckets.delete(key); } } @@ -59,19 +50,23 @@ export async function rateLimit(key: string, limit: number, windowMs: number): P cleanup(); - const bucket = buckets.get(key); + const windowKey = `mem:${key}`; + const bucket = buckets.get(windowKey); + if (!bucket || now >= bucket.resetAt) { - buckets.set(key, { count: 1, resetAt: now + windowMs }); + buckets.set(windowKey, { count: 1, resetAt: now + windowMs }); return { ok: true, retryAfter: 0 }; } - if (bucket.count >= limit) { + + const newCount = bucket.count + 1; + if (newCount > limit) { return { ok: false, retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)) }; } - bucket.count += 1; + + bucket.count = newCount; return { ok: true, retryAfter: 0 }; } -/** Best-effort client IP from the proxy headers our edge proxy forwards. */ export async function clientIp(): Promise { try { const h = await headers(); diff --git a/src/lib/safe-action-shared.ts b/src/lib/safe-action-shared.ts index 22bd014a..c1506f56 100644 --- a/src/lib/safe-action-shared.ts +++ b/src/lib/safe-action-shared.ts @@ -1,11 +1,9 @@ import { ZodError } from "zod"; - -// ── Action Result Type ─────────────────────────────────────────────── +import { handleActionError as foundationHandle } from "@/lib/foundation/action"; export type ActionResult> = - { ok: true; data?: T } | { ok: false; error: string; fieldErrors?: Record }; - -// ── Helper to build results ────────────────────────────────────────── + | { ok: true; data?: T } + | { ok: false; error: string; fieldErrors?: Record }; export function actionOk>(data?: T): ActionResult { return { ok: true, data: data ?? ({} as T) }; @@ -15,8 +13,6 @@ export function actionError(message: string): ActionResult { return { ok: false, error: message }; } -// ── Throwable error ────────────────────────────────────────────────── - export class ActionError extends Error { constructor(message: string) { super(message); @@ -24,8 +20,6 @@ export class ActionError extends Error { } } -// ── Error handler ──────────────────────────────────────────────────── - export function handleActionError(error: unknown): ActionResult { if (error instanceof ZodError) { return { @@ -37,14 +31,5 @@ export function handleActionError(error: unknown): ActionResult { if (error instanceof Error && error.name === "ActionError") { return { ok: false, error: error.message }; } - // Prisma P2025 - if ( - error instanceof Error && - error.constructor.name === "PrismaClientKnownRequestError" && - (error as Error & { code?: string }).code === "P2025" - ) { - return { ok: false, error: "Not found" }; - } - console.error("[Action error]", error); - return { ok: false, error: "Internal server error" }; + return foundationHandle(error) as ActionResult; } diff --git a/src/lib/safe-action.ts b/src/lib/safe-action.ts index 5034df17..6f1a117d 100644 --- a/src/lib/safe-action.ts +++ b/src/lib/safe-action.ts @@ -1,132 +1,7 @@ -import type { z } from "zod"; -import { auth } from "@/lib/auth"; -import { canAccess, getApiAdminContext } from "@/lib/permissions"; -import { type ActionResult, actionError, handleActionError } from "@/lib/safe-action-shared"; -import { logAuthorizationEvent } from "@/lib/admin/authorization-events"; +import { adminAction as foundationAdmin, authAction as foundationAuth } from "@/lib/foundation/action"; +import type { ActionResult } from "@/lib/safe-action-shared"; export type { ActionResult }; -// ── Admin action wrapper ───────────────────────────────────────────── - -interface AdminActionOptions { - permission?: string; - schema?: TSchema; -} - -type AdminActionContext = { - session: { user: { id: number; username: string; rank: number; look: string; mail: string } }; -} & (TSchema extends z.ZodType ? { data: z.infer } : object); - -/** - * Create a server action with admin auth + optional permission + optional Zod validation. - * - * @example - * export const updateNews = adminAction( - * { permission: PERMS.NEWS_EDIT, schema: updateNewsSchema }, - * async (ctx) => { - * await prisma.websiteArticle.update({ ... }) - * return actionOk() - * } - * ) - */ -export function adminAction( - options: AdminActionOptions, - handler: (ctx: AdminActionContext) => Promise, -) { - return async ( - // biome-ignore lint/suspicious/noConfusingVoidType: void in conditional return lets callers omit the arg when there's no schema - input: TSchema extends z.ZodType ? z.input : void, - ): Promise => { - try { - const apiCtx = await getApiAdminContext(); - if (!apiCtx) return actionError("Unauthorized"); - - if (options.permission) { - if (!canAccess(apiCtx.permissions, options.permission, apiCtx.session.user.rank)) { - await logAuthorizationEvent({ - kind: "permission.denied", - userId: apiCtx.session.user.id, - username: apiCtx.session.user.name ?? undefined, - rank: apiCtx.session.user.rank, - permission: options.permission, - source: "adminAction", - reason: "Permission check denied", - }); - return actionError("Unauthorized"); - } - } - - let data: unknown; - if (options.schema) { - const parsed = options.schema.safeParse(input); - if (!parsed.success) { - return { - ok: false, - error: "Validation failed", - fieldErrors: parsed.error.flatten().fieldErrors as Record, - }; - } - data = parsed.data; - } - - const ctx = { - session: apiCtx.session, - ...(options.schema ? { data } : {}), - } as AdminActionContext; - - return await handler(ctx); - } catch (error) { - return handleActionError(error); - } - }; -} - -// ── Auth action wrapper (no permissions) ───────────────────────────── - -interface AuthActionOptions { - schema?: TSchema; -} - -type AuthActionContext = { - session: { user: { id: number; username: string; rank: number; look: string; mail: string } }; -} & (TSchema extends z.ZodType ? { data: z.infer } : object); - -/** - * Create a server action with auth only (no permission check). - */ -export function authAction( - options: AuthActionOptions, - handler: (ctx: AuthActionContext) => Promise, -) { - return async ( - // biome-ignore lint/suspicious/noConfusingVoidType: void in conditional return lets callers omit the arg when there's no schema - input: TSchema extends z.ZodType ? z.input : void, - ): Promise => { - try { - const session = await auth(); - if (!session?.user) return actionError("Unauthorized"); - - let data: unknown; - if (options.schema) { - const parsed = options.schema.safeParse(input); - if (!parsed.success) { - return { - ok: false, - error: "Validation failed", - fieldErrors: parsed.error.flatten().fieldErrors as Record, - }; - } - data = parsed.data; - } - - const ctx = { - session, - ...(options.schema ? { data } : {}), - } as AuthActionContext; - - return await handler(ctx); - } catch (error) { - return handleActionError(error); - } - }; -} +export const adminAction = foundationAdmin; +export const authAction = foundationAuth; diff --git a/src/proxy.ts b/src/proxy.ts index f6dd2823..ab8f2340 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -2,10 +2,15 @@ import { NextResponse } from "next/server"; import { proxyAuth } from "@/lib/proxy-auth"; import { shouldRedirectAdminRequest } from "@/lib/proxy-access"; -// Edge proxy (formerly "middleware"): Prisma can't run here, so we only forward -// the request path (so server components / the access guard can read it via -// headers()) and normalize the real client IP. The DB-backed banned/maintenance -// checks happen in src/lib/access-guard.ts (Node runtime) from the root layout. +const SECURITY_HEADERS: Record = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-XSS-Protection": "0", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload", +}; + export const proxy = proxyAuth((req) => { if (shouldRedirectAdminRequest(req.nextUrl.pathname, req.auth?.user ?? null)) { return NextResponse.redirect(new URL("/login", req.url)); @@ -13,13 +18,21 @@ export const proxy = proxyAuth((req) => { const headers = new Headers(req.headers); headers.set("x-pathname", req.nextUrl.pathname); + const ip = req.headers.get("cf-connecting-ip") ?? req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? req.headers.get("x-real-ip") ?? ""; if (ip) headers.set("x-real-client-ip", ip); - return NextResponse.next({ request: { headers } }); + + const response = NextResponse.next({ request: { headers } }); + + for (const [key, value] of Object.entries(SECURITY_HEADERS)) { + response.headers.set(key, value); + } + + return response; }); export const config = {