diff --git a/src/actions/moderation.ts b/src/actions/moderation.ts index 7564f1bf..29685447 100644 --- a/src/actions/moderation.ts +++ b/src/actions/moderation.ts @@ -3,8 +3,8 @@ import { z } from "zod"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; -import { adminAction } from "@/lib/safe-action"; -import { ActionError, actionOk } from "@/lib/safe-action-shared"; +import { adminAction, actionOk } from "@/lib/foundation/action"; +import { NotFoundError } from "@/lib/foundation/errors"; import { logAudit } from "@/lib/services/audit"; import { rcon } from "@/lib/services/rcon"; @@ -16,7 +16,7 @@ export const assignCfhTicket = adminAction( { permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema }, async (ctx) => { const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } }); - if (!ticket) throw new ActionError("Ticket not found"); + if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId); await prisma.supportTickets.update({ where: { id: ctx.data.ticketId }, @@ -43,7 +43,7 @@ export const updateCfhState = adminAction( { permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema }, async (ctx) => { const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } }); - if (!ticket) throw new ActionError("Ticket not found"); + if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId); await prisma.supportTickets.update({ where: { id: ctx.data.ticketId }, @@ -104,7 +104,7 @@ export const quickKick = adminAction( const muteSchema = z.object({ userId: z.coerce.number().int().positive(), - duration: z.coerce.number().int().min(0).max(525600), // max 1 year in minutes + duration: z.coerce.number().int().min(0).max(525600), }); export const quickMute = adminAction( diff --git a/src/actions/user-settings.ts b/src/actions/user-settings.ts index d67154a7..37693139 100644 --- a/src/actions/user-settings.ts +++ b/src/actions/user-settings.ts @@ -1,40 +1,41 @@ "use server"; +import { z } from "zod"; import { revalidatePath } from "next/cache"; -import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { rcon } from "@/lib/services/rcon"; +import { authAction, actionOk } from "@/lib/foundation/action"; +import { DatabaseError } from "@/lib/foundation/errors"; -// Emulator motto column is VARCHAR(127); keep the CMS-side write within bounds. const MOTTO_MAX = 127; -/** - * Update the SIGNED-IN user's motto. The id is taken from the session - * (re-fetched via auth()), never from the submitted FormData, so a crafted - * form cannot mutate another account. Mirrors AtomCMS: persist + RCON setmotto - * so an online user sees the change live. - */ +const mottoSchema = z.object({ + motto: z.string().max(MOTTO_MAX, `Motto must be at most ${MOTTO_MAX} characters`), +}); + +const updateMottoAction = authAction( + { schema: mottoSchema }, + async (ctx) => { + try { + await prisma.user.update({ where: { id: ctx.session.user.id }, data: { motto: ctx.data.motto } }); + } catch { + throw new DatabaseError("Failed to update motto"); + } + + try { + await rcon.setMotto(ctx.session.user.id, ctx.data.motto); + } catch { + // RCON is best-effort; the change is already persisted. + } + + revalidatePath("/settings"); + return actionOk(); + }, +); + export async function updateMotto(formData: FormData): Promise { - const session = await auth(); - if (!session?.user?.id) return; - - const id = Number(session.user.id); - if (!Number.isFinite(id)) return; - const motto = String(formData.get("motto") ?? "").slice(0, MOTTO_MAX); - - try { - await prisma.user.update({ where: { id }, data: { motto } }); - } catch { - // DB unavailable — fail soft; nothing to persist. - return; - } - - try { - await rcon.setMotto(id, motto); - } catch { - // RCON is best-effort; the change is already persisted. - } - - revalidatePath("/settings"); + await updateMottoAction({ motto }); } + +export { updateMottoAction }; diff --git a/src/lib/access-guard.ts b/src/lib/access-guard.ts index 7fb9e6fa..1df75c37 100644 --- a/src/lib/access-guard.ts +++ b/src/lib/access-guard.ts @@ -4,56 +4,67 @@ import { auth } from "@/lib/auth"; import { isIpBlacklisted, recordRequest } from "@/lib/services/abuse-guard"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; +import { safeRedirect } from "@/lib/foundation/security"; +import { logger } from "@/lib/logger"; -// Paths that must never be gated (otherwise banned/maintenance loop forever). const EXEMPT = ["/banned", "/maintenance", "/login", "/register", "/forgot", "/reset", "/api"]; function isExempt(path: string): boolean { return EXEMPT.some((p) => path === p || path.startsWith(`${p}/`)); } -/** - * Site-wide access enforcement (called from the root layout): routes non-staff - * to /maintenance when maintenance mode is on, and banned users to /banned. - * Runs in the Node runtime so it can query the DB. The redirect decision is - * computed inside try/catch and the redirect() (which throws NEXT_REDIRECT) is - * issued OUTSIDE it. - */ export async function enforceSiteAccess(): Promise { const h = await headers(); const path = h.get("x-pathname") ?? "/"; const ip = h.get("x-real-client-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "0.0.0.0"; - // Abuse/DDoS guard: count this request and block flooding IPs (no-op unless - // enabled in settings). Best-effort — never let it throw past the guard. void recordRequest(ip).catch(() => {}); if (isExempt(path)) return; let target: string | null = null; - try { - // App-level IP blacklist (auto-populated by the abuse guard + /admin/ip). - if (await isIpBlacklisted(ip)) target = "/banned"; + let checksDegraded = false; + try { + if (await isIpBlacklisted(ip)) target = "/banned"; + } catch { + checksDegraded = true; + } + + try { const session = await auth(); const rank = session?.user?.rank ?? 0; - if (!target && (await siteSettings.getBool("maintenance_enabled", false))) { - const minLogin = Number(await siteSettings.get("min_maintenance_login_rank", "7")) || 7; - if (rank < minLogin) target = "/maintenance"; + try { + if (!target && (await siteSettings.getBool("maintenance_enabled", false))) { + const minLogin = Number(await siteSettings.get("min_maintenance_login_rank", "7")) || 7; + if (rank < minLogin) target = "/maintenance"; + } + } catch { + checksDegraded = true; } - if (!target && session?.user?.id) { - const now = Math.floor(Date.now() / 1000); - const ban = await prisma.ban.findFirst({ - where: { userId: Number(session.user.id), banExpire: { gt: now } }, - select: { id: true }, - }); - if (ban) target = "/banned"; + try { + if (!target && session?.user?.id) { + const now = Math.floor(Date.now() / 1000); + const ban = await prisma.ban.findFirst({ + where: { userId: Number(session.user.id), banExpire: { gt: now } }, + select: { id: true }, + }); + if (ban) target = "/banned"; + } + } catch { + checksDegraded = true; } } catch { - // On any failure, fail open (don't lock the whole site out on a DB hiccup). + checksDegraded = true; } - if (target) redirect(target); + if (target) { + redirect(safeRedirect(target, target)); + } + + if (checksDegraded) { + logger.warn("Access guard degraded — some checks skipped", { ip, path }); + } } diff --git a/src/lib/admin/guard.ts b/src/lib/admin/guard.ts index b7c614d1..4f76a054 100644 --- a/src/lib/admin/guard.ts +++ b/src/lib/admin/guard.ts @@ -1,7 +1,7 @@ -import { redirect } from "next/navigation"; import { isStaff } from "@/lib/admin/is-staff"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { redirectSafe } from "@/lib/foundation/security"; export { isStaff }; @@ -11,23 +11,15 @@ export interface StaffUser { username: string; } -/** - * Gate for admin pages and actions: redirects to /login when unauthenticated - * and to / when authenticated but not staff. Returns the staff user otherwise. - */ export async function requireStaff(): Promise { const { session, permissions } = await getAdminContext(); - if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank)) redirect("/"); + if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank)) redirectSafe("/", "/"); return { id: session.user.id, rank: session.user.rank, username: session.user.username }; } -/** - * Like requireStaff but also rate-limits the action per staff user (30 requests - * per minute). Use on sensitive admin actions (ban, rank-change, settings edit). - */ export async function requireStaffRateLimited(): Promise { const staff = await requireStaff(); const ip = await clientIp(); - if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok) redirect("/admin?error=ratelimit"); + if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok) redirectSafe("/admin?error=ratelimit", "/admin"); return staff; } diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index eea32c21..038111e2 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -3,6 +3,9 @@ import { NextResponse } from "next/server"; import { logAuthorizationEvent } from "@/lib/admin/authorization-events"; import { canAccess, getApiAdminContext } from "@/lib/permissions"; import { logServerError } from "@/lib/server-log"; +import { validateCsrfToken } from "@/lib/foundation/security"; + +const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); type AdminContext = NonNullable>>; type RouteContext = { params?: Promise> }; @@ -12,8 +15,16 @@ type AdminHandler = ( routeContext: RouteContext, ) => Promise | Response; -export function withAdmin(options: { permission?: string }, handler: AdminHandler) { +export function withAdmin(options: { permission?: string; requireCsrf?: boolean }, 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") ?? ""; + const valid = await validateCsrfToken(csrfToken); + if (!valid) { + return NextResponse.json({ ok: false, error: "Invalid or missing CSRF token" }, { status: 403 }); + } + } + const context = await getApiAdminContext(); if (!context) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); if ( diff --git a/src/lib/foundation/action.ts b/src/lib/foundation/action.ts new file mode 100644 index 00000000..80e86ae2 --- /dev/null +++ b/src/lib/foundation/action.ts @@ -0,0 +1,182 @@ +import type { z } from "zod"; +import { auth } from "@/lib/auth"; +import { canAccess, getApiAdminContext } from "@/lib/permissions"; +import { logAuthorizationEvent } from "@/lib/admin/authorization-events"; +import { getRequestId, runWithStore, createStore, setContextUserId } from "./request-context"; +import { NotFoundError, UnauthorizedError, ForbiddenError, ValidationError, RateLimitError, DatabaseError } from "./errors"; +import type { ActionResult, ActionSuccess, ActionFailure, AppSession, AdminActionContext, IpAddress, RequestId } from "./types"; +import { extractClientIpAsync } from "./security"; +import { rateLimit } from "@/lib/rate-limit"; + +function ok>(data?: T): ActionSuccess { + return { ok: true, data: (data ?? {}) as T } as unknown as ActionSuccess; +} + +function fail(error: string, fieldErrors?: Record): ActionFailure { + return { ok: false, error, fieldErrors }; +} + +export { ok as actionOk, fail as actionError }; + +interface AdminOpts { + permission?: string; + schema?: TSchema; + rateLimitKey?: string; + rateLimitMax?: number; + rateLimitWindowMs?: number; +} + +type ActionHandler = ( + ctx: AdminActionContext & (TSchema extends z.ZodType ? { data: z.infer } : Record), +) => Promise; + +export function adminAction( + opts: AdminOpts, + handler: ActionHandler, +) { + return async ( + input: TSchema extends z.ZodType ? z.input : void, + ): Promise => { + const ip = await extractClientIpAsync(); + const store = createStore(ip); + + return runWithStore(store, async () => { + try { + const apiCtx = await getApiAdminContext(); + if (!apiCtx) return fail("Unauthorized"); + + setContextUserId(Number(apiCtx.session.user.id) as never); + + if (opts.permission) { + if (!canAccess(apiCtx.permissions, opts.permission, apiCtx.session.user.rank)) { + await logAuthorizationEvent({ + kind: "permission.denied", + userId: Number(apiCtx.session.user.id), + username: apiCtx.session.user.name ?? undefined, + rank: apiCtx.session.user.rank, + permission: opts.permission, + source: "adminAction", + reason: "Permission check denied", + }); + return fail("Unauthorized"); + } + } + + if (opts.rateLimitKey && opts.rateLimitMax && opts.rateLimitWindowMs) { + const rlKey = `${opts.rateLimitKey}:${ip}`; + const result = await rateLimit(rlKey, opts.rateLimitMax, opts.rateLimitWindowMs); + if (!result.ok) return fail(`Rate limited. Retry in ${result.retryAfter}s.`); + } + + let data: unknown; + if (opts.schema) { + const parsed = opts.schema.safeParse(input); + if (!parsed.success) { + return fail("Validation failed", parsed.error.flatten().fieldErrors as Record); + } + data = parsed.data; + } + + const ctx = { + session: apiCtx.session, + permissions: apiCtx.permissions, + requestId: getRequestId(), + ip, + ...(opts.schema ? { data: data as z.infer> } : {}), + } as AdminActionContext & (TSchema extends z.ZodType ? { data: z.infer } : Record); + + return await handler(ctx); + } catch (error) { + return handleActionError(error); + } + }); + }; +} + +interface AuthOpts { + schema?: TSchema; + rateLimitKey?: string; + rateLimitMax?: number; + rateLimitWindowMs?: number; +} + +export function authAction( + opts: AuthOpts, + handler: (ctx: { session: AppSession; requestId: RequestId; ip: IpAddress } & (TSchema extends z.ZodType ? { data: z.infer } : Record)) => Promise, +) { + return async ( + input: TSchema extends z.ZodType ? z.input : void, + ): Promise => { + const ip = await extractClientIpAsync(); + const store = createStore(ip); + + return runWithStore(store, async () => { + try { + const session = await auth(); + if (!session?.user) return fail("Unauthorized"); + + setContextUserId(Number(session.user.id) as never); + + if (opts.rateLimitKey && opts.rateLimitMax && opts.rateLimitWindowMs) { + const rlKey = `${opts.rateLimitKey}:${ip}`; + const result = await rateLimit(rlKey, opts.rateLimitMax, opts.rateLimitWindowMs); + if (!result.ok) return fail(`Rate limited. Retry in ${result.retryAfter}s.`); + } + + let data: unknown; + if (opts.schema) { + const parsed = opts.schema.safeParse(input); + if (!parsed.success) { + return fail("Validation failed", parsed.error.flatten().fieldErrors as Record); + } + data = parsed.data; + } + + const ctx = { + session: session as unknown as AppSession, + requestId: getRequestId(), + ip, + } as { session: AppSession; requestId: RequestId; ip: IpAddress } & (TSchema extends z.ZodType ? { data: z.infer } : Record); + + if (opts.schema) { + (ctx as Record).data = data as z.infer>; + } + + return await handler(ctx); + } catch (error) { + return handleActionError(error); + } + }); + }; +} + +export function handleActionError(error: unknown): ActionFailure { + if (error instanceof ValidationError) { + return fail(error.message, error.fieldErrors); + } + if (error instanceof UnauthorizedError || error instanceof ForbiddenError) { + return fail(error.message); + } + if (error instanceof NotFoundError) { + return fail(error.message); + } + if (error instanceof RateLimitError) { + return fail(error.message); + } + if (error instanceof DatabaseError) { + return fail("A database error occurred"); + } + if (error instanceof Error && error.name === "ZodError") { + return fail("Validation failed"); + } + if ( + error instanceof Error && + (error.constructor.name === "PrismaClientKnownRequestError" || error.name === "PrismaClientKnownRequestError") && + (error as Error & { code?: string }).code === "P2025" + ) { + return fail("Not found"); + } + + console.error("[Action error]", error instanceof Error ? { message: error.message, name: error.name } : error); + return fail("Internal server error"); +} diff --git a/src/lib/foundation/database.ts b/src/lib/foundation/database.ts new file mode 100644 index 00000000..e71a701d --- /dev/null +++ b/src/lib/foundation/database.ts @@ -0,0 +1,118 @@ +import { PrismaMariaDb } from "@prisma/adapter-mariadb"; +import { PrismaClient } from "@/generated/prisma/client"; +import { env } from "@/env"; +import { logger } from "@/lib/logger"; +import { DatabaseError } from "./errors"; +import { getRequestId } from "./request-context"; + +const globalForDb = globalThis as unknown as { _db?: DbService }; + +interface HealthStatus { + ok: boolean; + latencyMs: number; + poolSize: number; + activeQueries: number; + error?: string; +} + +export class DbService { + private readonly client: PrismaClient; + private queryCount = 0; + private lastHealthCheck = 0; + private healthCache: HealthStatus | null = null; + private readonly healthTtlMs = 10_000; + + constructor() { + const url = new URL(env.DATABASE_URL); + const adapter = new PrismaMariaDb({ + host: url.hostname, + port: Number(url.port) || 3306, + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + database: url.pathname.replace(/^\//, ""), + connectionLimit: env.DATABASE_POOL_SIZE, + connectTimeout: env.DATABASE_CONNECT_TIMEOUT_MS, + acquireTimeout: env.DATABASE_CONNECT_TIMEOUT_MS, + idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS, + }); + + this.client = new PrismaClient({ + adapter, + log: env.NODE_ENV === "development" ? [{ emit: "event", level: "query" }, { emit: "event", level: "error" }] : [{ emit: "event", level: "error" }], + }); + + if (env.NODE_ENV === "development") { + this.client.$on("query" as never, (e: unknown) => { + const ev = e as { query: string; duration: number }; + logger.debug("DB query", { query: ev.query.slice(0, 200), durationMs: ev.duration, requestId: getRequestId() }); + }); + } + + this.client.$on("error" as never, (e: unknown) => { + const ev = e as { message: string }; + logger.error("DB error", { message: ev.message, requestId: getRequestId() }); + }); + } + + get prisma(): PrismaClient { + return this.client; + } + + async health(): Promise { + const now = Date.now(); + if (this.healthCache && now - this.lastHealthCheck < this.healthTtlMs) { + return this.healthCache; + } + + const start = performance.now(); + try { + await this.client.$queryRaw`SELECT 1`; + const latencyMs = Math.round(performance.now() - start); + this.healthCache = { ok: true, latencyMs, poolSize: env.DATABASE_POOL_SIZE, activeQueries: 0 }; + this.lastHealthCheck = now; + return this.healthCache; + } catch (cause) { + const latencyMs = Math.round(performance.now() - start); + const message = cause instanceof Error ? cause.message : "Unknown database error"; + this.healthCache = { ok: false, latencyMs, poolSize: env.DATABASE_POOL_SIZE, activeQueries: 0, error: message }; + this.lastHealthCheck = now; + return this.healthCache; + } + } + + async execute(fn: (client: PrismaClient) => Promise): Promise { + this.queryCount++; + try { + return await fn(this.client); + } catch (cause) { + throw new DatabaseError("Query failed", cause); + } + } + + async transaction(fn: (tx: Omit) => Promise): Promise { + try { + return await this.client.$transaction(fn); + } catch (cause) { + throw new DatabaseError("Transaction failed", cause); + } + } + + async rawQuery(strings: TemplateStringsArray, ...values: unknown[]): Promise { + try { + return await this.client.$queryRaw(strings, ...values); + } catch (cause) { + throw new DatabaseError("Raw query failed", cause); + } + } + + async executeRaw(query: string, ...values: unknown[]): Promise { + try { + return await this.client.$executeRawUnsafe(query, ...values); + } catch (cause) { + throw new DatabaseError("Execute raw failed", cause); + } + } +} + +export const db = globalForDb._db ?? (globalForDb._db = new DbService()); +if (env.NODE_ENV !== "production") globalForDb._db = db; diff --git a/src/lib/foundation/errors.ts b/src/lib/foundation/errors.ts new file mode 100644 index 00000000..8a0ab001 --- /dev/null +++ b/src/lib/foundation/errors.ts @@ -0,0 +1,67 @@ +export class DomainError extends Error { + public readonly status: number; + + constructor(message: string, status: number = 500) { + super(message); + this.name = "DomainError"; + this.status = status; + } +} + +export class NotFoundError extends DomainError { + constructor(entity: string, id?: number | string) { + super(id ? `${entity} #${id} not found` : `${entity} not found`, 404); + this.name = "NotFoundError"; + } +} + +export class UnauthorizedError extends DomainError { + constructor(message: string = "Unauthorized") { + super(message, 401); + this.name = "UnauthorizedError"; + } +} + +export class ForbiddenError extends DomainError { + constructor(message: string = "Forbidden") { + super(message, 403); + this.name = "ForbiddenError"; + } +} + +export class ValidationError extends DomainError { + public readonly fieldErrors: Record; + + constructor(fieldErrors: Record) { + super("Validation failed", 422); + this.name = "ValidationError"; + this.fieldErrors = fieldErrors; + } +} + +export class RateLimitError extends DomainError { + public readonly retryAfter: number; + + constructor(retryAfter: number) { + super(`Rate limited. Try again in ${retryAfter}s.`, 429); + this.name = "RateLimitError"; + this.retryAfter = retryAfter; + } +} + +export class ConflictError extends DomainError { + constructor(message: string) { + super(message, 409); + this.name = "ConflictError"; + } +} + +export class DatabaseError extends DomainError { + public readonly cause: unknown; + + constructor(message: string, cause?: unknown) { + super(message, 500); + this.name = "DatabaseError"; + this.cause = cause; + } +} diff --git a/src/lib/foundation/index.ts b/src/lib/foundation/index.ts new file mode 100644 index 00000000..152f63d1 --- /dev/null +++ b/src/lib/foundation/index.ts @@ -0,0 +1,87 @@ +export { + adminAction, + authAction, + actionOk, + actionError, + handleActionError, +} from "./action"; + +export { + DbService, + db, +} from "./database"; + +export { + safeRedirect, + redirectSafe, + setCsrfCookie, + validateCsrfToken, + canonicalize, + sanitizeFilename, + canonicalizeFormValue, + canonicalizeFormData, + extractClientIpAsync, +} from "./security"; + +export { + NotFoundError, + UnauthorizedError, + ForbiddenError, + ValidationError, + RateLimitError, + ConflictError, + DatabaseError, + DomainError, +} from "./errors"; + +export { + getRequestStore, + getRequestId, + getClientIp, + setContextUserId, + elapsed, + createStore, + runWithStore, +} from "./request-context"; + +export { + chain, + withRequestContext, + protectAdminRoutes, + addSecurityHeaders, +} from "./middleware"; + +export { + username, + password, + email, + hexColor, + slug, + url, + look, + positiveInt, + nonNegativeInt, + bigIntString, + idParam, + pagination, + boolString, + buildSearchQuery, +} from "./validation"; + +export type { + UserId, + RankId, + IpAddress, + RequestId, + SessionUser, + AppSession, + ActionContext, + AdminActionContext, + ActionResult, + ActionSuccess, + ActionFailure, + PaginatedQuery, + PaginatedResult, + PermissionSet, + RequestContext, +} from "./types"; diff --git a/src/lib/foundation/middleware.ts b/src/lib/foundation/middleware.ts new file mode 100644 index 00000000..1a5d81ea --- /dev/null +++ b/src/lib/foundation/middleware.ts @@ -0,0 +1,93 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { extractClientIpAsync, safeRedirect } from "./security"; +import { runWithStore, createStore, getRequestId } from "./request-context"; +import { logger } from "@/lib/logger"; + +type MiddlewareHandler = (req: NextRequest) => Promise; + +export function chain(...handlers: MiddlewareHandler[]): MiddlewareHandler { + return async (req: NextRequest) => { + for (const handler of handlers) { + const result = await handler(req); + if (result) return result; + } + return NextResponse.next(); + }; +} + +export function withRequestContext(handler: MiddlewareHandler): MiddlewareHandler { + return async (req: NextRequest) => { + const ip = await extractClientIpAsync(); + const store = createStore(ip); + return runWithStore(store, async () => { + const start = Date.now(); + try { + const result = await handler(req); + const duration = Date.now() - start; + logger.info("Request completed", { + method: req.method, + path: req.nextUrl.pathname, + status: result?.status ?? 200, + durationMs: duration, + requestId: getRequestId(), + }); + return result; + } catch (error) { + const duration = Date.now() - start; + logger.error("Request failed", { + method: req.method, + path: req.nextUrl.pathname, + durationMs: duration, + requestId: getRequestId(), + error: error instanceof Error ? error.message : String(error), + }); + const safeUrl = new URL(safeRedirect(req.nextUrl.pathname, "/"), req.url); + return NextResponse.redirect(safeUrl); + } + }); + }; +} + +export function protectAdminRoutes(req: NextRequest): NextResponse | null { + const { pathname } = req.nextUrl; + + if (!pathname.startsWith("/admin")) return null; + + const authToken = req.cookies.get("next-auth.session-token")?.value + ?? req.cookies.get("__Secure-next-auth.session-token")?.value; + + if (!authToken) { + const loginUrl = new URL("/login", req.url); + loginUrl.searchParams.set("callbackUrl", pathname); + return NextResponse.redirect(loginUrl); + } + + return null; +} + +export function addSecurityHeaders(req: NextRequest): NextResponse | null { + if (req.method === "OPTIONS") return null; + + const response = NextResponse.next(); + const csp = [ + "default-src 'self'", + "script-src 'self' 'unsafe-eval' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https: http:", + "font-src 'self' https:", + "connect-src 'self' https: wss:", + "frame-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join("; "); + + response.headers.set("Content-Security-Policy", csp); + response.headers.set("X-Content-Type-Options", "nosniff"); + response.headers.set("X-Frame-Options", "DENY"); + response.headers.set("X-XSS-Protection", "0"); + response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + + return response; +} diff --git a/src/lib/foundation/request-context.ts b/src/lib/foundation/request-context.ts new file mode 100644 index 00000000..ad68aa43 --- /dev/null +++ b/src/lib/foundation/request-context.ts @@ -0,0 +1,53 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { IpAddress, RequestId, UserId } from "./types"; + +export interface RequestStore { + requestId: RequestId; + ip: IpAddress; + userId: UserId | null; + startedAt: number; +} + +const als = new AsyncLocalStorage(); + +let counter = 0; + +function generateRequestId(): RequestId { + counter = (counter + 1) & 0xffff; + return `${Date.now().toString(36)}-${counter.toString(36)}-${crypto.randomUUID().slice(0, 8)}` as RequestId; +} + +export function createStore(ip: IpAddress): RequestStore { + return { + requestId: generateRequestId(), + ip, + userId: null, + startedAt: Date.now(), + }; +} + +export function runWithStore(store: RequestStore, fn: () => T): T { + return als.run(store, fn); +} + +export function getRequestStore(): RequestStore | null { + return als.getStore() ?? null; +} + +export function getRequestId(): RequestId { + return als.getStore()?.requestId ?? (generateRequestId()); +} + +export function getClientIp(): IpAddress { + return als.getStore()?.ip ?? ("0.0.0.0" as IpAddress); +} + +export function setContextUserId(userId: UserId): void { + const store = als.getStore(); + if (store) store.userId = userId; +} + +export function elapsed(): number { + const store = als.getStore(); + return store ? Date.now() - store.startedAt : 0; +} diff --git a/src/lib/foundation/security.ts b/src/lib/foundation/security.ts new file mode 100644 index 00000000..e207cc45 --- /dev/null +++ b/src/lib/foundation/security.ts @@ -0,0 +1,128 @@ +import { headers } from "next/headers"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import crypto from "node:crypto"; +import { env } from "@/env"; +import type { IpAddress } from "./types"; + +const CSRF_BYTES = 32; +const CSRF_COOKIE = "__Host-csrf-token"; +const CSRF_COOKIE_MAX_AGE = 86400; // 24h + +const ALLOWED_HOSTS: ReadonlySet = new Set([ + env.APP_URL ? new URL(env.APP_URL).host : "", + "localhost", + "127.0.0.1", +].filter(Boolean)); + +const SAFE_REDIRECT_PATHS = new Set([ + "/login", "/register", "/forgot", "/reset", "/verify", + "/banned", "/maintenance", "/", "/me", "/settings", +]); + +function isSafePath(path: string): boolean { + if (!path.startsWith("/")) return false; + if (SAFE_REDIRECT_PATHS.has(path)) return true; + if (path.startsWith("/admin/") || path.startsWith("/api/")) return true; + return false; +} + +export function safeRedirect(destination: string, fallback: string = "/"): string { + try { + const url = new URL(destination, env.APP_URL || "http://localhost:3000"); + if (ALLOWED_HOSTS.has(url.host)) return destination; + if (url.host === "localhost" || url.host === "127.0.0.1") return destination; + } catch { + if (isSafePath(destination)) return destination; + } + return fallback; +} + +export function redirectSafe(destination: string, fallback: string = "/"): never { + redirect(safeRedirect(destination, fallback)); +} + +function csrfCookieOpts(): { name: string; value: string; httpOnly: boolean; secure: boolean; sameSite: "lax"; path: string; maxAge: number } { + return { + name: CSRF_COOKIE, + value: crypto.randomBytes(CSRF_BYTES).toString("hex"), + httpOnly: true, + secure: true, + sameSite: "lax" as const, + path: "/", + maxAge: CSRF_COOKIE_MAX_AGE, + }; +} + +export async function setCsrfCookie(): Promise { + const c = await cookies(); + const existing = c.get(CSRF_COOKIE); + if (existing?.value && existing.value.length === CSRF_BYTES * 2) return existing.value; + const opts = csrfCookieOpts(); + c.set(opts.name, opts.value, opts); + return opts.value; +} + +export async function validateCsrfToken(token: string): Promise { + if (!token || token.length !== CSRF_BYTES * 2) return false; + try { + const c = await cookies(); + const stored = c.get(CSRF_COOKIE)?.value; + if (!stored || stored.length !== CSRF_BYTES * 2) return false; + return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(stored)); + } catch { + return false; + } +} + +export function canonicalize(input: string): string { + return input.normalize("NFC").trim(); +} + +const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/; + +function removeControlChars(s: string): string { + let result = ""; + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i); + if (code >= 32) result += s.charAt(i); + } + return result; +} + +export function sanitizeFilename(name: string): string { + return removeControlChars( + name + .normalize("NFC") + .replace(INVALID_FILENAME_CHARS, "") + .replace(/\.\.(?:\/|$)/g, ""), + ) + .trim() + .slice(0, 255); +} + +export function canonicalizeFormValue(value: FormDataEntryValue | null, maxLen?: number): string { + const s = canonicalize(String(value ?? "")); + return maxLen ? s.slice(0, maxLen) : s; +} + +export function canonicalizeFormData(formData: FormData, fields: Record): Record { + return Object.fromEntries( + Object.entries(fields).map(([key, maxLen]) => [key, canonicalizeFormValue(formData.get(key), maxLen)]), + ); +} + +export async function extractClientIpAsync(): Promise { + try { + const h = await headers(); + return ( + h.get("x-real-client-ip") ?? + h.get("cf-connecting-ip") ?? + h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + h.get("x-real-ip") ?? + "0.0.0.0" + ) as IpAddress; + } catch { + return "0.0.0.0" as IpAddress; + } +} diff --git a/src/lib/foundation/types.ts b/src/lib/foundation/types.ts new file mode 100644 index 00000000..c657406e --- /dev/null +++ b/src/lib/foundation/types.ts @@ -0,0 +1,71 @@ +declare const Brand: unique symbol; +type Branded = T & { readonly [Brand]: B }; + +export type UserId = Branded; +export type RankId = Branded; +export type IpAddress = Branded; +export type RequestId = Branded; + +export interface SessionUser { + id: UserId; + username: string; + rank: RankId; + look: string; + mail: string; +} + +export interface AppSession { + user: SessionUser; + expires: string; +} + +export interface ActionContext { + session: AppSession; + requestId: RequestId; + ip: IpAddress; +} + +export interface AdminActionContext extends ActionContext { + permissions: PermissionSet; +} + +export interface ActionSuccess> { + ok: true; + data: T; +} +export interface ActionFailure { + ok: false; + error: string; + fieldErrors?: Record; +} +export type ActionResult> = ActionSuccess | ActionFailure; + +export interface PaginatedQuery { + page: number; + perPage: number; + sort?: string; + order?: "asc" | "desc"; + search?: string; +} + +export interface PaginatedResult { + rows: T[]; + total: number; + page: number; + perPage: number; + lastPage: number; +} + +export interface PermissionSet { + has(permission: string): boolean; + hasAny(...permissions: string[]): boolean; + hasAll(...permissions: string[]): boolean; + isSuperAdmin: boolean; +} + +export interface RequestContext { + requestId: RequestId; + ip: IpAddress; + userId: UserId | null; + startedAt: number; +} diff --git a/src/lib/foundation/validation.ts b/src/lib/foundation/validation.ts new file mode 100644 index 00000000..bc3dd7a6 --- /dev/null +++ b/src/lib/foundation/validation.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; + +const USERNAME_RE = /^[a-zA-Z0-9\-_.]+$/; + +export const username = z + .string() + .trim() + .min(1, "Username is required") + .max(32, "Username must be at most 32 characters") + .regex(USERNAME_RE, "Username may only contain letters, numbers, hyphens, underscores, and dots") + .transform((v) => v.normalize("NFC")); + +export const password = z + .string() + .min(8, "Password must be at least 8 characters") + .max(128, "Password must be at most 128 characters"); + +export const email = z + .string() + .trim() + .email("Invalid email address") + .max(255, "Email must be at most 255 characters") + .transform((v) => v.normalize("NFC").toLowerCase()); + +const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i; +const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; + +export const hexColor = z + .string() + .length(7, "Must be exactly 7 characters (e.g. #ff0000)") + .regex(HEX_COLOR_RE, "Must be a valid hex color (e.g. #ff0000)"); + +export const slug = z + .string() + .trim() + .min(1, "Slug is required") + .max(64, "Slug must be at most 64 characters") + .regex(SLUG_RE, "Slug must be lowercase alphanumeric with hyphens only between characters") + .refine((v) => !v.startsWith("-") && !v.endsWith("-"), "Slug must not start or end with a hyphen") + .transform((v) => v.normalize("NFC")); + +export const url = z + .string() + .url("Invalid URL") + .max(2048, "URL must be at most 2048 characters"); + +export const look = z + .string() + .max(512, "Look string must be at most 512 characters") + .regex(/^[a-zA-Z0-9.\-_\s]+$/, "Invalid look format") + .optional(); + +export const positiveInt = z + .number() + .int("Must be a whole number") + .positive("Must be positive"); + +export const nonNegativeInt = z + .number() + .int("Must be a whole number") + .nonnegative("Must not be negative"); + +export const bigIntString = z + .string() + .regex(/^\d+$/, "Must be a numeric string") + .transform(BigInt); + +export const idParam = z + .string() + .regex(/^\d+$/, "ID must be numeric") + .transform(Number); + +export const pagination = z.object({ + page: z.coerce.number().int().positive().default(1), + perPage: z.coerce.number().int().min(1).max(250).default(50), + sort: z.string().optional(), + order: z.enum(["asc", "desc"]).optional(), + search: z.string().max(256).optional(), +}); + +export const boolString = z + .string() + .transform((v) => v === "true" || v === "1") + .or(z.boolean()); + +export function buildSearchQuery(fields: string[], search: string | undefined) { + if (!search || !search.trim()) return undefined; + const sanitized = search.normalize("NFC").trim().slice(0, 256); + return fields.map((f) => ({ [f]: { contains: sanitized } })); +} diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index 2b68220c..851bb94c 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -1,5 +1,4 @@ import { unstable_cache } from "next/cache"; -import { redirect } from "next/navigation"; import { cache } from "react"; import { auth } from "./auth"; import { sessionUserId } from "./auth/session-user"; @@ -7,6 +6,7 @@ import { prisma } from "./prisma"; import { logAuthorizationEvent } from "./admin/authorization-events"; import { isDynamicSuperAdmin } from "./admin/authorization-policy"; import { resolveAuthorizationState } from "./admin/rank-authority"; +import { redirectSafe } from "./foundation/security"; // Re-export PERMS from the standalone file (safe for client components) export { PERMS } from "./permission-slugs"; @@ -121,13 +121,13 @@ const getCurrentAuthorizationState = cache(async (userId: number) => export async function getAdminContext() { const session = await auth(); if (!session?.user) { - redirect("/login"); + redirectSafe("/login", "/login"); } const userId = sessionUserId(session.user.id); - if (!userId) redirect("/login"); + if (!userId) redirectSafe("/login", "/login"); const state = await getCurrentAuthorizationState(userId); - if (!state) redirect("/login"); + if (!state) redirectSafe("/login", "/login"); const permissions = await loadUserPermissions(userId, state.actor.rank, state.highestRank); return { session: { @@ -175,15 +175,15 @@ export function canAccess(permissions: PermissionSet, slug: string, _rank?: numb export async function getModContext() { const session = await auth(); if (!session?.user) { - redirect("/login"); + redirectSafe("/login", "/login"); } const userId = sessionUserId(session.user.id); - if (!userId) redirect("/login"); + if (!userId) redirectSafe("/login", "/login"); const state = await getCurrentAuthorizationState(userId); - if (!state) redirect("/"); + if (!state) redirectSafe("/", "/"); const permissions = await loadUserPermissions(userId, state.actor.rank, state.highestRank); - if (!canAccess(permissions, PERMS.MOD_DASHBOARD)) redirect("/"); + if (!canAccess(permissions, PERMS.MOD_DASHBOARD)) redirectSafe("/", "/"); return { session: { ...session, diff --git a/src/lib/services/abuse-guard.ts b/src/lib/services/abuse-guard.ts index ff00f91f..c05a6126 100644 --- a/src/lib/services/abuse-guard.ts +++ b/src/lib/services/abuse-guard.ts @@ -2,17 +2,6 @@ import { ddosDetected } from "@/lib/services/alert"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; -/** - * App-level abuse / DDoS guard — the web-tier-feasible half of AtomCMS's DDoS - * protection. It can't touch iptables (that's a host-only optimisation), but it - * DOES the actual mitigation a CMS needs: count requests per IP and, when one - * floods past the threshold, add it to website_ip_blacklist (which the access - * guard then enforces) and fire the existing ddosDetected() alert. - * - * OFF by default; staff enable + tune it via website_settings: - * abuse_guard_enabled ("1"), abuse_guard_threshold (req, default 200), - * abuse_guard_window_seconds (default 10). - */ type Bucket = { count: number; resetAt: number }; const buckets = new Map(); const recentlyBlocked = new Set(); @@ -21,6 +10,32 @@ let blacklist = new Set(); let blacklistLoadedAt = 0; const BLACKLIST_TTL = 30_000; +const MAX_BUCKETS = 10_000; +const MAX_RECENTLY_BLOCKED = 1_000; +const CLEANUP_INTERVAL = 300_000; +let lastCleanup = Date.now(); + +function cleanupStaleEntries(): void { + const now = Date.now(); + if (now - lastCleanup < CLEANUP_INTERVAL) return; + lastCleanup = now; + + for (const [k, v] of buckets) { + if (now >= v.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); + const keys = sorted.slice(0, toRemove).map((entry) => entry[0]); + for (const key of keys) buckets.delete(key); + } + + if (recentlyBlocked.size > MAX_RECENTLY_BLOCKED) { + recentlyBlocked.clear(); + } +} + function isPrivate(ip: string): boolean { return ( !ip || @@ -32,7 +47,6 @@ function isPrivate(ip: string): boolean { ); } -/** Cached blacklist lookup (refreshed every 30s — no DB hit per request). */ export async function isIpBlacklisted(ip: string): Promise { if (isPrivate(ip)) return false; const now = Date.now(); @@ -48,7 +62,6 @@ export async function isIpBlacklisted(ip: string): Promise { return blacklist.has(ip); } -/** Count a request; auto-blacklist + alert the IP if it floods (when enabled). */ export async function recordRequest(ip: string): Promise { if (isPrivate(ip)) return; if (!(await siteSettings.getBool("abuse_guard_enabled", false))) return; @@ -57,9 +70,8 @@ export async function recordRequest(ip: string): Promise { const windowMs = (Number(await siteSettings.get("abuse_guard_window_seconds", "10")) || 10) * 1000; const now = Date.now(); - if (buckets.size > 10_000) { - for (const [k, v] of buckets) if (now >= v.resetAt) buckets.delete(k); - } + + cleanupStaleEntries(); const b = buckets.get(ip); if (!b || now >= b.resetAt) { @@ -75,7 +87,7 @@ export async function recordRequest(ip: string): Promise { await prisma.websiteIpBlacklist.create({ data: { ipAddress: ip, createdAt: new Date(), updatedAt: new Date() }, }); - blacklistLoadedAt = 0; // force a refresh so the block takes effect at once + blacklistLoadedAt = 0; await ddosDetected(ip, b.count); } catch { /* ignore — alert/blacklist best-effort */