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
@@ -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 */
|
||||
|
||||
Reference in new issue
Block a user