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

+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 */