Distributed Rate Limiter & Edge Proxy CSP
Pertahanan lapis depan menggunakan proxy Next.js 16 ber-nonce CSP dan pembatas laju request terdistribusi di tabel PostgreSQL.
Membaca sekitar 2 menit
Pertahanan lapis depan menggunakan proxy Next.js 16 ber-nonce CSP dan pembatas laju request terdistribusi di tabel PostgreSQL.
Proxy menyuntikkan header Content-Security-Policy di setiap request, sementara assertRateLimit mencatat kuota per IP/user secara atomik di PostgreSQL dengan fallback otomatis ke memori lokal.
Saat database sedang sibuk atau terputus sesaat, aplikasi tidak boleh lumpuh total namun juga tidak boleh kehilangan perisai pembatas request-nya.
Masalahnya dulu
Aplikasi web modern menghadapi dua ancaman konstan dari luar: serangan injeksi skrip (XSS) di sisi browser dan serangan banjir request (brute-force) di sisi server. Mengandalkan variabel Map di memori server untuk membatasi request akan gagal ketika aplikasi dijalankan di beberapa instance container, namun memaksa semua pengecekan gagal ketika database mengalami gangguan sesaat juga akan membuat aplikasi tidak bisa diakses oleh pengguna sah.
Pertahanan berlapis dari proxy.ts hingga rate_limit_buckets
Di pintu paling depan, file proxy.ts (pengganti middleware.ts di Next.js 16) membangkitkan cryptographic nonce unik untuk setiap request dan menyuntikkan header Content-Security-Policy serta mengalihkan pengguna yang belum login. Di lapisan dalam, common/lib/rate-limit.ts mencatat hitungan request secara terpusat di tabel PostgreSQL rate_limit_buckets melalui transaksi upsert atomik. Jika suatu saat koneksi PostgreSQL terputus, blok catch otomatis beralih menggunakan MemoryRateLimitStore di RAM sebagai perisai cadangan.
Pertahanan yang baik bekerja terpusat saat cuaca cerah, dan otomatis beralih ke pertahanan lokal tanpa membuat layanan runtuh saat badai datang.
Implementasi di ngodingan ini
Berikut adalah potongan transaksi pembatas laju request dengan pembersihan probabilistik dan fallback in-memory di common/lib/rate-limit.ts:
export async function assertRateLimit(key: string, limit: number, windowMs: number) {
const now = new Date();
const resetAt = new Date(now.getTime() + windowMs);
try {
const bucket = await prisma.$transaction(async (tx) => {
const existing = await tx.rateLimitBucket.findUnique({ where: { key } });
if (!existing || existing.resetAt <= now) {
return tx.rateLimitBucket.upsert({
where: { key },
create: { key, count: 1, resetAt },
update: { count: 1, resetAt },
});
}
if (existing.count >= limit) return existing;
return tx.rateLimitBucket.update({
where: { key },
data: { count: { increment: 1 } },
});
});
if (bucket.count >= limit && bucket.resetAt > now) {
const diffMs = bucket.resetAt.getTime() - now.getTime();
if (diffMs > 0) throw new RateLimitError(Math.max(1, Math.ceil(diffMs / 1000)));
}
// Pembersihan berkala (probabilistik 1 dari 50 request) agar tabel tidak membengkak
if (Math.random() < 0.02) {
pruneExpiredRateLimits().catch(() => {});
}
} catch (err) {
if (err instanceof RateLimitError) throw err;
assertMemoryRateLimit(key, limit, windowMs);
}
}Jebakan dan detail yang harus dijaga
- Mengecualikan rute /api/* dari redirect proxy.ts: Rute API harus mengembalikan status HTTP 401/403 JSON lewat requirePermission, bukan di-redirect ke halaman HTML /login oleh proxy.