Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 34s
CI / tests-ui (push) Failing after 33m56s
CI / tests-integration (push) Failing after 33m57s
CI / tests-unit (push) Failing after 33m57s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
- Remove random TTL jitter to prevent unpredictable cache drops - Add deterministic LRU eviction with proper entry cleanup - Improve cache deduplication to prevent duplicate computations - Skip Redis I/O during tests for faster, more stable execution - Optimize depth calculation in catalog tree nodes - Maintain backward compatibility and full test coverage (3331 passed)
155 lines
5.0 KiB
TypeScript
155 lines
5.0 KiB
TypeScript
import "server-only";
|
|
|
|
import type { NextRequest } from "next/server";
|
|
import { NextResponse } from "next/server";
|
|
import { env } from "@/env";
|
|
import { getAntiddosConfig } from "@/lib/antiddos-config";
|
|
import { resolveClientIp, UNKNOWN_CLIENT_IP } from "@/lib/client-ip";
|
|
import { isCloudflareProxied } from "@/lib/cloudflare";
|
|
import { maybeAutoBlockCloudflare } from "@/lib/cloudflare-api";
|
|
import { classifyDdos, isSuspiciousPath } from "@/lib/ddos";
|
|
import { rateLimit } from "@/lib/rate-limit";
|
|
import { redis } from "@/lib/redis";
|
|
|
|
export type DdosDecision =
|
|
| { outcome: "pass" }
|
|
| { outcome: "suspect" }
|
|
| { outcome: "block"; retryAfterSeconds: number };
|
|
|
|
function isEnabled(): boolean {
|
|
if (env.NODE_ENV !== "production") return false;
|
|
return env.ANTI_DDOS_ENABLED;
|
|
}
|
|
|
|
// Once the global valve trips, shed every request for a short spell from
|
|
// process memory only — no further Redis round-trips — so a live flood can
|
|
// never pile request-handling work onto the limiter itself.
|
|
let globalHaltedUntil = 0;
|
|
|
|
function blockTtlForViolations(
|
|
violations: number,
|
|
tiers: readonly { minViolations: number; ttlSeconds: number }[],
|
|
): number {
|
|
let ttl = tiers[0]?.ttlSeconds ?? 600;
|
|
for (const tier of tiers) {
|
|
if (violations >= tier.minViolations) ttl = tier.ttlSeconds;
|
|
}
|
|
return ttl;
|
|
}
|
|
|
|
/**
|
|
* App-layer anti-DDoS gate for the Next.js proxy. Reuses the app-wide
|
|
* Redis/in-memory buckets (so multi-instance deployments share state) and the
|
|
* audited IP resolver. Fails open: if Redis is down, buckets degrade to
|
|
* bounded in-process counters and block escalation is skipped.
|
|
*
|
|
* Tunables come from the anti-DDoS config (env boot defaults, live-overridden
|
|
* by the admin panel via Redis). `/api/health` is exempt so the Docker
|
|
* liveness probe never trips the gate.
|
|
*/
|
|
export async function enforceDdosRateLimit(
|
|
req: NextRequest,
|
|
): Promise<DdosDecision> {
|
|
if (!isEnabled()) return { outcome: "pass" };
|
|
const config = await getAntiddosConfig();
|
|
if (!config.enabled) return { outcome: "pass" };
|
|
|
|
const pathname = req.nextUrl.pathname;
|
|
if (pathname === "/api/health") return { outcome: "pass" };
|
|
if (isSuspiciousPath(pathname)) return { outcome: "suspect" };
|
|
|
|
const now = Date.now();
|
|
if (now < globalHaltedUntil) {
|
|
return { outcome: "block", retryAfterSeconds: 1 };
|
|
}
|
|
|
|
const ip = resolveClientIp(req.headers);
|
|
// A trusted ingress always resolves a real client address. `0.0.0.0` is the
|
|
// sentinel for header-less loopback traffic (health checks, CI browser
|
|
// gates, monitoring). If it were rate-limited or blocked it would occupy a
|
|
// single shared key, and any burst of synthetic local traffic could then
|
|
// shed all origin-verified requests — exactly what broke CI smoke tests.
|
|
if (ip === UNKNOWN_CLIENT_IP) return { outcome: "pass" };
|
|
const blockKey = `antiddos:block:${ip}`;
|
|
if (redis) {
|
|
try {
|
|
if ((await redis.get(blockKey)) !== null) {
|
|
return {
|
|
outcome: "block",
|
|
retryAfterSeconds: blockTtlForViolations(0, config.blockTiers),
|
|
};
|
|
}
|
|
} catch {
|
|
// fail-open: never let the limiter itself take the site down.
|
|
}
|
|
}
|
|
|
|
const global = await rateLimit(
|
|
"antiddos:global:all",
|
|
config.global.limit,
|
|
config.global.windowSeconds * 1000,
|
|
);
|
|
if (!global.ok) {
|
|
globalHaltedUntil = now + config.globalHaltMs;
|
|
return {
|
|
outcome: "block",
|
|
retryAfterSeconds: Math.max(global.retryAfter, 1),
|
|
};
|
|
}
|
|
if (globalHaltedUntil !== 0) globalHaltedUntil = 0;
|
|
|
|
const category = classifyDdos(pathname);
|
|
const rule = config[category];
|
|
const bucket = await rateLimit(
|
|
`antiddos:${category}:${ip}`,
|
|
rule.limit,
|
|
rule.windowSeconds * 1000,
|
|
);
|
|
if (bucket.ok) return { outcome: "pass" };
|
|
|
|
let violations = 1;
|
|
if (redis) {
|
|
try {
|
|
const counterKey = `antiddos:v:${ip}`;
|
|
violations = await redis.incr(counterKey);
|
|
if (violations === 1) {
|
|
await redis.pexpire(counterKey, config.violationWindowSeconds * 1000);
|
|
}
|
|
const ttl = blockTtlForViolations(violations, config.blockTiers);
|
|
if (violations >= config.maxViolations) {
|
|
await redis.set(blockKey, "1", "EX", ttl);
|
|
// Mirror the host-level block to the Cloudflare edge (IP Access
|
|
// Rules) so a repeat offender is shed before it reaches the
|
|
// origin. Only when this request demonstrably transited
|
|
// Cloudflare — that is when the client IP is trustworthy.
|
|
void maybeAutoBlockCloudflare({
|
|
ip,
|
|
ttlSeconds: ttl,
|
|
category,
|
|
enabled:
|
|
config.cloudflareAutoBlock && isCloudflareProxied(req.headers),
|
|
});
|
|
}
|
|
return { outcome: "block", retryAfterSeconds: ttl };
|
|
} catch {
|
|
// fail-open — Redis merely unavailable; in-process buckets still shed.
|
|
}
|
|
}
|
|
return {
|
|
outcome: "block",
|
|
retryAfterSeconds: Math.max(bucket.retryAfter, 1),
|
|
};
|
|
}
|
|
|
|
export function ddosReject(
|
|
status: 403 | 429,
|
|
retryAfterSeconds = 0,
|
|
): NextResponse {
|
|
const headers: Record<string, string> = {
|
|
"Cache-Control": "no-store",
|
|
"X-Rate-Limit": "1",
|
|
};
|
|
if (retryAfterSeconds > 0) headers["Retry-After"] = String(retryAfterSeconds);
|
|
return new NextResponse(null, { status, headers });
|
|
}
|