Add EpicNext CMS foundation layer and fix critical security gaps
Local Build and Deploy / deploy (push) Successful in 1m1s
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:
1 parent
8bf1aa2fa7
commit
f6ad030c5b
16 files changed
+1012
-96
No files matched your search
+36
-25
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user