Endpoint autentikasi dan mutasi data sensitif pada aplikasi web merupakan target utama serangan credential stuffing dan brute force. Nuxt 3 mengeksekusi logika backend menggunakan Nitro engine yang telah terintegrasi dengan unstorage. Abstraksi key-value storage terpadu ini memungkinkan implementasi kontrol laju pemanggilan API (rate limiting) yang fleksibel, mulai dari in-memory saat pengembangan hingga Redis untuk kluster multi-instance di production tanpa perlu mengubah kode logika inti.
Arsitektur Rate Limiting dengan unstorage
Nitro menyediakan useStorage() secara global di lingkungan server. Pendekatan sliding window log dipilih karena menawarkan akurasi tinggi dibanding fixed window counter sederhana yang rentan lonjakan lalu lintas ganda di batas interval waktu.
Data request disimpan sebagai array timestamp per IP klien. Setiap request baru memicu pembersihan timestamp kedaluwarsa di luar durasi window, evaluasi batas kuota, dan penulisan ulang state terbaru dengan Time-To-Live (TTL).
1. Konfigurasi Driver Storage Nitro
Definisikan konfigurasi mount point storage khusus di file nuxt.config.ts. Gunakan driver memory untuk lingkungan lokal dan driver Redis jika aplikasi berjalan di production dengan konfigurasi kontainer atau serverless terdistribusi.
// nuxt.config.ts
import { defineNuxtConfig } from 'nuxt/config'
export default defineNuxtConfig({
nitro: {
storage: {
rateLimit: process.env.NODE_ENV === 'production'
? {
driver: 'redis',
url: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
ttl: 60 // fallback default ttl dalam detik
}
: {
driver: 'memory'
}
}
}
})2. Algoritma Sliding Window Rate Limiter
Buat utility mandiri di server/utils/rateLimit.ts untuk mengenkapsulasi pengecekan kuota, pembaruan data request, serta kalkulasi header standar RFC (RateLimit-* dan Retry-After).
// server/utils/rateLimit.ts
import type { H3Event } from 'h3'
interface RateLimitOptions {
windowMs: number
maxRequests: number
}
interface RateLimitResult {
allowed: boolean
limit: number
remaining: number
resetSeconds: number
retryAfter: number
}
export async function checkRateLimit(
identifier: string,
options: RateLimitOptions
): Promise<RateLimitResult> {
const storage = useStorage('rateLimit')
const now = Date.now()
const key = `rate:${identifier}`
// Ambil history timestamp atau inisialisasi array kosong
const rawTimestamps = await storage.getItem<number[]>(key)
const timestamps = Array.isArray(rawTimestamps) ? rawTimestamps : []
// Buang timestamp di luar window aktif
const validTimestamps = timestamps.filter((t) => now - t < options.windowMs)
const limit = options.maxRequests
const remaining = Math.max(0, limit - validTimestamps.length)
const oldestTimestamp = validTimestamps[0] || now
const resetTimestamp = oldestTimestamp + options.windowMs
const resetSeconds = Math.max(1, Math.ceil((resetTimestamp - now) / 1000))
if (validTimestamps.length >= limit) {
return {
allowed: false,
limit,
remaining: 0,
resetSeconds,
retryAfter: resetSeconds
}
}
// Tambahkan timestamp sekarang dan simpan kembali dengan TTL
validTimestamps.push(now)
const ttlSeconds = Math.ceil(options.windowMs / 1000)
await storage.setItem(key, validTimestamps, { ttl: ttlSeconds })
return {
allowed: true,
limit,
remaining: remaining - 1,
resetSeconds,
retryAfter: 0
}
}3. Implementasi Nitro Server Middleware
Middleware server mengevaluasi request yang masuk sebelum mencapai endpoint handler. Middleware ini mengekstrak IP klien menggunakan getRequestIP, memeriksa path URL yang tergolong sensitif, dan menghentikan eksekusi dengan status HTTP 429 jika kuota terlampaui.
// server/middleware/rateLimit.ts
import { defineEventHandler, getRequestIP, setHeaders, createError } from 'h3'
import { checkRateLimit } from '../utils/rateLimit'
export default defineEventHandler(async (event) => {
const path = event.node.req.url || ''
// Terapkan throttling hanya pada endpoint mutasi autentikasi
if (path.startsWith('/api/auth/') && event.node.req.method === 'POST') {
// Validasi proxy terpercaya via opsi xForwardedFor
const clientIp = getRequestIP(event, { xForwardedFor: true }) || '127.0.0.1'
// Konfigurasi: 5 request per 60 detik (60000 ms)
const { allowed, limit, remaining, resetSeconds, retryAfter } = await checkRateLimit(
`auth:${clientIp}`,
{ windowMs: 60 * 1000, maxRequests: 5 }
)
// Tetapkan header kepatuhan RateLimit IETF
setHeaders(event, {
'RateLimit-Limit': String(limit),
'RateLimit-Remaining': String(remaining),
'RateLimit-Reset': String(resetSeconds)
})
if (!allowed) {
setHeaders(event, {
'Retry-After': String(retryAfter)
})
throw createError({
statusCode: 429,
statusMessage: 'Too Many Requests',
message: 'Batas percobaan request terlampaui. Silakan coba beberapa saat lagi.'
})
}
}
})4. Pengujian Otomatis Menggunakan Vitest
Uji keandalan algoritma rate limiting secara langsung dengan Vitest untuk memverifikasi isolasi threshold dan status kuota.
// tests/rateLimit.spec.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { checkRateLimit } from '../server/utils/rateLimit'
describe('Sliding Window Rate Limiter via unstorage', () => {
const testIp = '192.168.1.100'
const options = { windowMs: 2000, maxRequests: 3 }
beforeEach(async () => {
const storage = useStorage('rateLimit')
await storage.clear()
})
it('mengizinkan request di bawah batas kuota', async () => {
const res1 = await checkRateLimit(testIp, options)
expect(res1.allowed).toBe(true)
expect(res1.remaining).toBe(2)
const res2 = await checkRateLimit(testIp, options)
expect(res2.allowed).toBe(true)
expect(res2.remaining).toBe(1)
})
it('menolak request ketika kuota telah habis dan mengembalikan retryAfter', async () => {
for (let i = 0; i < 3; i++) {
await checkRateLimit(testIp, options)
}
const blocked = await checkRateLimit(testIp, options)
expect(blocked.allowed).toBe(false)
expect(blocked.remaining).toBe(0)
expect(blocked.retryAfter).toBeGreaterThan(0)
})
})Limitasi dan Pertimbangan Keamanan
- Header Spoofing: Parameter
xForwardedFor: truepadagetRequestIPhanya boleh diaktifkan jika aplikasi berada di balik Reverse Proxy tepercaya (seperti Nginx, Cloudflare, atau AWS ALB) yang membersihkan headerX-Forwarded-Formasuk. Jika terekspos langsung ke publik, klien dapat memalsukan IP untuk memotong pembatasan kuota. - Race Condition: Operasi
getItemdiikutisetItemdi unstorage bukan merupakan transaksi atomik. Pada skala throughput sangat masif (ribuan request per detik per kunci IP), gunakan Lua script langsung di Redis atau Redis atomic command guna mencegah anomali konkurensi. - Beban Memori Redis: Hindari interval window yang berlebihan (misalnya beberapa hari) dengan sliding window log karena array timestamp dapat membengkak. Batasi TTL Redis secara eksplisit pada setiap mutasi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!