Appearance
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ớp | Hook | Key | Mặc định | Áp dụng cho | Mục đích |
|---|---|---|---|---|---|
| Global | onRequest | 'global' | 1000/1s | tất cả | Backstop chống DoS cho tổng throughput |
| Per-IP | onRequest | request.ip | 50/1s | chỉ ẩn danh | Chống brute-force cho login/register |
| Per-User | preHandler | userId (fallback ip) | 300/60s | đã đăng nhập | Giới hạn công bằng/chống abuse theo từng danh tính |
Per-IP bỏ qua request đã đăng nhập.
rateLimiterIPreturn sớm khi córequest.token(doextractTokenset, 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:
| Config | Backend | Trường hợp dùng |
|---|---|---|
RATE_LIMITER_STORE=memory | RateLimiterMemory | 1 instance, không phụ thuộc gì thêm |
RATE_LIMITER_STORE=redis + REDIS_ENABLED=true | RateLimiterRedis | Nhiề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:
- Đị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. - IP client thật cho traffic ẩn danh. BFF forward IP client quan sát được qua
X-Forwarded-For, và API suy rarequest.iptừ đó theoTRUST_PROXY. SetTRUST_PROXY= số hop proxy tin cậy đứng trước API (vd1choBrowser → 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-Fordo 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ạysetupAccountability, 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/medí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 đổi | Tác dụng |
|---|---|
| Per-IP limiter bỏ qua request đã auth | Burst 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=1 | Giớ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ến | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
RATE_LIMITER_ENABLED | boolean | true | Bật/tắt tổng cho mọi limiter |
RATE_LIMITER_STORE | string | 'memory' | 'memory' hoặc 'redis' |
TRUST_PROXY | số hop / true / false / CIDR | 1 | Số hop proxy tin cậy để suy request.ip từ X-Forwarded-For. 1 = Browser → BFF → API |
RATE_LIMITER_GLOBAL_ENABLED | boolean | true | Bật/tắt global limiter |
RATE_LIMITER_GLOBAL_POINTS | number | 1000 | Global: số request tối đa mỗi window |
RATE_LIMITER_GLOBAL_DURATION | number | 1 | Global: window (giây) |
RATE_LIMITER_POINTS | number | 50 | Per-IP (ẩn danh): số request tối đa mỗi window |
RATE_LIMITER_DURATION | number | 1 | Per-IP: window (giây) |
RATE_LIMITER_USER_POINTS | number | 300 | Per-User: số request tối đa mỗi window |
RATE_LIMITER_USER_DURATION | number | 60 | Per-User: window (giây) |
File nguồn
| File | Vai trò |
|---|---|
src/security/rate-limit/store.ts | Factory: tạo RateLimiterMemory hoặc RateLimiterRedis |
src/security/rate-limit/per-user.ts | Per-user preHandler hook (key = accountability.user ?? ip) |
src/middleware/rate-limiter.ts | Global + per-IP onRequest hook (per-IP bỏ qua request đã auth), email limiter |
src/middleware/extract-token.ts | Set request.token (chạy trước per-IP, bật cơ chế skip) |
src/server.ts | trustProxy: env.TRUST_PROXY |
Phần cache accountability và forward
X-Forwarded-Fornằm ở tầng app/BFF (apps/app/packages/core-api/server/utils/accountability-cache.tsvàsetup-api-context.ts) — xem App docs.