Skip to content

Rate Limiter

Rate limiting nhiều lớp, hỗ trợ Redis cho deployment nhiều instance. Thiết kế nhận biết BFF: traffic đã đăng nhập bị giới hạn theo danh tính user (không theo IP), nên vẫn chính xác khi API chạy sau một Backend-for-Frontend gom mọi request về cùng một IP.

Kiến trúc

Các lớp

LớpHookKeyMặc địnhÁp dụng choMục đích
GlobalonRequest'global'1000/1stất cảBackstop chống DoS cho tổng throughput
Per-IPonRequestrequest.ip50/1schỉ ẩn danhChống brute-force cho login/register
Per-UserpreHandleruserId (fallback ip)300/60sđã đăng nhậpGiới hạn công bằng/chống abuse theo từng danh tính

Per-IP bỏ qua request đã đăng nhập. rateLimiterIP return sớm khi có request.token (do extractToken set, chạy trước nó). Traffic đã auth do per-user limiter quản — keyed theo user id (không giả mạo được), nên không phụ thuộc IP của caller. Đây chính là điểm giúp limiter đúng khi chạy sau BFF (xem Chạy sau BFF).

Ngoài ra còn một email limiter riêng (5 req/60s theo IP) bảo vệ các endpoint password reset và invitation.

Chọn backend store

Factory ở src/security/rate-limit/store.ts tạo backend phù hợp:

ConfigBackendTrường hợp dùng
RATE_LIMITER_STORE=memoryRateLimiterMemory1 instance, không phụ thuộc gì thêm
RATE_LIMITER_STORE=redis + REDIS_ENABLED=trueRateLimiterRedisNhiều instance, đếm chung

Xử lý khi Redis lỗi

Khi dùng Redis, mỗi limiter có insuranceLimiter (in-memory) tự kích hoạt nếu Redis mất kết nối:

  • Rate limiting không bao giờ fail-open — fallback in-memory vẫn enforce
  • Trong lúc Redis chết: đếm theo từng instance (kém chính xác hơn nhưng vẫn bảo vệ)
  • Tự phục hồi khi Redis kết nối lại

Định dạng key Redis: odp:rate:{prefix}:{key}

Chạy sau BFF (reverse proxy)

API thường chạy sau một Backend-for-Frontend (app Nuxt) hoặc reverse proxy khác, nên mọi request tới API đều xuất phát từ IP của proxy. Hai cơ chế giữ cho việc giới hạn vẫn chính xác:

  1. Định danh theo user, không theo IP, cho traffic đã auth. Per-IP limiter bị bỏ qua khi có token; per-user limiter tiếp quản, keyed theo accountability.user. Mỗi user thật có budget riêng, bất kể bao nhiêu user dùng chung IP proxy.
  2. IP client thật cho traffic ẩn danh. BFF forward IP client quan sát được qua X-Forwarded-For, và API suy ra request.ip từ đó theo TRUST_PROXY. Set TRUST_PROXY = số hop proxy tin cậy đứng trước API (vd 1 cho Browser → BFF → API; tăng lên nếu có edge proxy/CDN phía trước). Không để true ở production — vì như vậy là tin cả X-Forwarded-For do client tự gửi, kẻ tấn công có thể spoof để né per-IP limiter.

BFF còn cache accountability (readMe + /users/me/policies) theo access token trong một TTL ngắn, nên không phải gọi lại API cho mỗi request /api/*. Nếu không có cache này, một lần load trang sẽ bung ra hàng chục request BFF × 2–3 backend call mỗi request — chính là nguyên nhân của sự cố bên dưới.

Case study — 429 ngay sau khi đăng nhập

Triệu chứng. Ngay sau khi login thành công, request /auth/me đầu tiên (và cả dashboard) trả về 429 Too Many Requests.

Nguyên nhân gốc (hai yếu tố cộng hưởng).

  • API chạy sau Nuxt BFF nên mọi backend call đến từ một IP. Per-IP limiter (50/1s) vì thế hoạt động như một cap dùng chung cho cả app, không còn theo từng user.
  • Mỗi request /api/* của BFF chạy setupAccountability, gọi backend 2 lần (readMe + /users/me/policies). Một lần load dashboard ~30 request BFF → ~90 backend hit trong chưa đầy 1 giây từ cùng IP đó → vượt cap 50/1s, và /auth/me dính đòn.

Vì sao tắt limiter là sai. Đặt RATE_LIMITER_ENABLED=false gỡ bỏ toàn bộ bảo vệ, kể cả chống brute-force login.

Cách fix (nhiều lớp).

Thay đổiTác dụng
Per-IP limiter bỏ qua request đã authBurst sau login không còn đụng cap per-IP-của-BFF
Per-user limiter quản traffic đã auth (300/60s)Budget công bằng theo danh tính, không giả mạo được, không phụ thuộc proxy
BFF cache accountability theo token (TTL ~30s)/users/me/policies từ mỗi-request giảm còn 1 lần/window — backend call mỗi request từ ~3 còn ~1
BFF forward IP client thật + TRUST_PROXY=1Giới hạn ẩn danh/login trở lại đúng theo từng attacker thật

Đã kiểm chứng. Sau fix, 60 lần /auth/me liên tiếp đều trả 200 (trước đó 429 từ lần thứ 7); burst 400 lần đụng đúng cap per-user (236×200 rồi 429), xác nhận bảo vệ chống abuse vẫn nguyên — keyed theo user, không theo IP của BFF.

Lưu ý vận hành. Một file .env cấu hình sai (RATE_LIMITER_USER_POINTS=8) đã hạ budget per-user xuống thấp hơn nhiều so với default code. Giữ .env khớp với default bên dưới, trừ khi cố ý siết chặt hơn.

Định dạng response

Khi bị giới hạn, response kèm header Retry-After:

http
HTTP/1.1 429 Too Many Requests
Retry-After: 5
Content-Type: application/json

{
  "errors": [{
    "message": "Hit rate limit",
    "extensions": {
      "code": "REQUESTS_EXCEEDED",
      "limit": 50,
      "reset": "2026-06-15T10:00:05Z"
    }
  }]
}

Security Events

Mỗi lần rate limit kích hoạt sẽ emit event RATE_LIMIT_TRIGGERED (severity: warning):

json
{
  "limiter": "per-user",
  "key": "user-uuid-here",
  "ip": "192.168.1.100"
}

Cấu hình

BiếnKiểuMặc địnhMô tả
RATE_LIMITER_ENABLEDbooleantrueBật/tắt tổng cho mọi limiter
RATE_LIMITER_STOREstring'memory''memory' hoặc 'redis'
TRUST_PROXYsố hop / true / false / CIDR1Số hop proxy tin cậy để suy request.ip từ X-Forwarded-For. 1 = Browser → BFF → API
RATE_LIMITER_GLOBAL_ENABLEDbooleantrueBật/tắt global limiter
RATE_LIMITER_GLOBAL_POINTSnumber1000Global: số request tối đa mỗi window
RATE_LIMITER_GLOBAL_DURATIONnumber1Global: window (giây)
RATE_LIMITER_POINTSnumber50Per-IP (ẩn danh): số request tối đa mỗi window
RATE_LIMITER_DURATIONnumber1Per-IP: window (giây)
RATE_LIMITER_USER_POINTSnumber300Per-User: số request tối đa mỗi window
RATE_LIMITER_USER_DURATIONnumber60Per-User: window (giây)

File nguồn

FileVai trò
src/security/rate-limit/store.tsFactory: tạo RateLimiterMemory hoặc RateLimiterRedis
src/security/rate-limit/per-user.tsPer-user preHandler hook (key = accountability.user ?? ip)
src/middleware/rate-limiter.tsGlobal + per-IP onRequest hook (per-IP bỏ qua request đã auth), email limiter
src/middleware/extract-token.tsSet request.token (chạy trước per-IP, bật cơ chế skip)
src/server.tstrustProxy: env.TRUST_PROXY

Phần cache accountability và forward X-Forwarded-For nằm ở tầng app/BFF (apps/app/packages/core-api/server/utils/accountability-cache.tssetup-api-context.ts) — xem App docs.

ODP Internal API Documentation