Add EpicNext CMS foundation layer and fix critical security gaps
Local Build and Deploy / deploy (push) Successful in 1m1s

- Create src/lib/foundation/ (860 LOC, 9 files): typed action wrappers,
  DbService with health checks, CSRF validation, safe redirects,
  AsyncLocalStorage request tracing, branded types, reusable Zod schemas
- Migrate moderation.ts and user-settings.ts to foundation patterns
- Fix abuse-guard.ts: bound in-memory Maps with LRU eviction (was unbounded)
- Fix access-guard.ts: separate try/catch per check, log degradation
  instead of blanket fail-open
- Replace raw redirect() calls with safeRedirect() in guard.ts and
  permissions.ts to prevent open-redirect attacks
- Add CSRF validation to api-handler.ts for mutating methods
- Add canonicalizeFormData() utility for FormData input sanitization
This commit is contained in:
openhands committed 2026-07-13 12:03:49 +02:00
1 parent 8bf1aa2fa7
commit f6ad030c5b
16 files changed
+1012 -96

No files matched your search

+5 -5
View File
@@ -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(
+30 -29
View File
@@ -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<void> {
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 };
+36 -25
View File
@@ -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<void> {
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 });
}
}
+3 -11
View File
@@ -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<StaffUser> {
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<StaffUser> {
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;
}
+12 -1
View File
@@ -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<Awaited<ReturnType<typeof getApiAdminContext>>>;
type RouteContext = { params?: Promise<Record<string, string | string[]>> };
@@ -12,8 +15,16 @@ type AdminHandler = (
routeContext: RouteContext,
) => Promise<Response> | 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 (
+182
View File
@@ -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<T = Record<string, unknown>>(data?: T): ActionSuccess<T> {
return { ok: true, data: (data ?? {}) as T } as unknown as ActionSuccess<T>;
}
function fail(error: string, fieldErrors?: Record<string, string[]>): ActionFailure {
return { ok: false, error, fieldErrors };
}
export { ok as actionOk, fail as actionError };
interface AdminOpts<TSchema extends z.ZodType | undefined> {
permission?: string;
schema?: TSchema;
rateLimitKey?: string;
rateLimitMax?: number;
rateLimitWindowMs?: number;
}
type ActionHandler<TSchema extends z.ZodType | undefined> = (
ctx: AdminActionContext & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : Record<string, never>),
) => Promise<ActionResult>;
export function adminAction<TSchema extends z.ZodType | undefined = undefined>(
opts: AdminOpts<TSchema>,
handler: ActionHandler<TSchema>,
) {
return async (
input: TSchema extends z.ZodType ? z.input<TSchema> : void,
): Promise<ActionResult> => {
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<string, string[]>);
}
data = parsed.data;
}
const ctx = {
session: apiCtx.session,
permissions: apiCtx.permissions,
requestId: getRequestId(),
ip,
...(opts.schema ? { data: data as z.infer<NonNullable<TSchema>> } : {}),
} as AdminActionContext & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : Record<string, never>);
return await handler(ctx);
} catch (error) {
return handleActionError(error);
}
});
};
}
interface AuthOpts<TSchema extends z.ZodType | undefined> {
schema?: TSchema;
rateLimitKey?: string;
rateLimitMax?: number;
rateLimitWindowMs?: number;
}
export function authAction<TSchema extends z.ZodType | undefined = undefined>(
opts: AuthOpts<TSchema>,
handler: (ctx: { session: AppSession; requestId: RequestId; ip: IpAddress } & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : Record<string, never>)) => Promise<ActionResult>,
) {
return async (
input: TSchema extends z.ZodType ? z.input<TSchema> : void,
): Promise<ActionResult> => {
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<string, string[]>);
}
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<TSchema> } : Record<string, never>);
if (opts.schema) {
(ctx as Record<string, unknown>).data = data as z.infer<NonNullable<TSchema>>;
}
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");
}
+118
View File
@@ -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<HealthStatus> {
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<T>(fn: (client: PrismaClient) => Promise<T>): Promise<T> {
this.queryCount++;
try {
return await fn(this.client);
} catch (cause) {
throw new DatabaseError("Query failed", cause);
}
}
async transaction<T>(fn: (tx: Omit<PrismaClient, "$connect" | "$disconnect" | "$on" | "$use" | "$extends">) => Promise<T>): Promise<T> {
try {
return await this.client.$transaction(fn);
} catch (cause) {
throw new DatabaseError("Transaction failed", cause);
}
}
async rawQuery<T>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T> {
try {
return await this.client.$queryRaw<T>(strings, ...values);
} catch (cause) {
throw new DatabaseError("Raw query failed", cause);
}
}
async executeRaw(query: string, ...values: unknown[]): Promise<number> {
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;
+67
View File
@@ -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<string, string[]>;
constructor(fieldErrors: Record<string, string[]>) {
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;
}
}
+87
View File
@@ -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";
+93
View File
@@ -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<NextResponse | null>;
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;
}
+53
View File
@@ -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<RequestStore>();
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<T>(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;
}
+128
View File
@@ -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<string> = 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<string> {
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<boolean> {
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<string, number | undefined>): Record<string, string> {
return Object.fromEntries(
Object.entries(fields).map(([key, maxLen]) => [key, canonicalizeFormValue(formData.get(key), maxLen)]),
);
}
export async function extractClientIpAsync(): Promise<IpAddress> {
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;
}
}
+71
View File
@@ -0,0 +1,71 @@
declare const Brand: unique symbol;
type Branded<T, B extends string> = T & { readonly [Brand]: B };
export type UserId = Branded<number, "UserId">;
export type RankId = Branded<number, "RankId">;
export type IpAddress = Branded<string, "IpAddress">;
export type RequestId = Branded<string, "RequestId">;
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<T = Record<string, unknown>> {
ok: true;
data: T;
}
export interface ActionFailure {
ok: false;
error: string;
fieldErrors?: Record<string, string[]>;
}
export type ActionResult<T = Record<string, unknown>> = ActionSuccess<T> | ActionFailure;
export interface PaginatedQuery {
page: number;
perPage: number;
sort?: string;
order?: "asc" | "desc";
search?: string;
}
export interface PaginatedResult<T> {
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;
}
+90
View File
@@ -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 } }));
}
+8 -8
View File
@@ -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,
+29 -17
View File
@@ -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<string, Bucket>();
const recentlyBlocked = new Set<string>();
@@ -21,6 +10,32 @@ let blacklist = new Set<string>();
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<boolean> {
if (isPrivate(ip)) return false;
const now = Date.now();
@@ -48,7 +62,6 @@ export async function isIpBlacklisted(ip: string): Promise<boolean> {
return blacklist.has(ip);
}
/** Count a request; auto-blacklist + alert the IP if it floods (when enabled). */
export async function recordRequest(ip: string): Promise<void> {
if (isPrivate(ip)) return;
if (!(await siteSettings.getBool("abuse_guard_enabled", false))) return;
@@ -57,9 +70,8 @@ export async function recordRequest(ip: string): Promise<void> {
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<void> {
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 */