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: true pada getRequestIP hanya boleh diaktifkan jika aplikasi berada di balik Reverse Proxy tepercaya (seperti Nginx, Cloudflare, atau AWS ALB) yang membersihkan header X-Forwarded-For masuk. Jika terekspos langsung ke publik, klien dapat memalsukan IP untuk memotong pembatasan kuota.
  • Race Condition: Operasi getItem diikuti setItem di 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.