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 principal (danh tính user hoặc service account — 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. Quyết định tier được đưa ra sau authenticate, khi đã biết token thật/giả và role của principal.
Kiến trúc
Ba tầng theo principal
| Principal | Key | Tier (env) | Mặc định |
|---|---|---|---|
Service account — accountability.service_account === true (role có flag) | accountability.user (mỗi service account một xô) | RATE_LIMITER_SERVICE_POINTS / _DURATION | 10000/60s |
User thường — có accountability.user, role không có flag | accountability.user | RATE_LIMITER_USER_POINTS / _DURATION | 300/60s |
| Ẩn danh — không token | request.ip | RATE_LIMITER_POINTS / _DURATION | 50/1s (đã trừ ở onRequest) |
| Token rác — có token nhưng mọi strategy fail | request.ip | như ẩn danh | trừ budget Per-IP trước khi trả 401 |
Không có nhánh nào "skip hoàn toàn": request nào cũng bị tính vào đúng một xô (ngoài global).
Flag service_account trên role
odp_roles.service_account (boolean, default false, migration 085) đánh dấu role của service principal: token static của BFF (appApi / ODP_BACKEND_TOKEN), batch job, integration. Một service token resolve thành một user cố định nhưng gánh traffic của rất nhiều end-user — nếu tính theo tier user (300/60s) thì mọi end-user đi qua BFF bị gộp vào một xô và sập dưới tải bình thường (sự cố batch-ingest). Tier service cho mỗi service account một budget riêng, lớn hơn nhiều; một service hỏng chỉ cạn xô của chính nó (fault isolation).
Flag được đọc cùng chỗ với tech_access / app_access ở mọi strategy của authenticate (JWT, static token, session token, sub-token kế thừa từ role của parent user) và được lưu vào session cache (SessionData.service_account) nên cache-hit không tốn thêm query. Session cache tạo trước khi có flag → coi là false.
Traffic public đi qua service token KHÔNG có giới hạn theo từng end-user ở core — đây là chủ đích: core chỉ thấy service account, không thấy end-user phía sau BFF. Nếu cần per-end-user, BFF tự làm (key theo session/IP của end-user) trước khi gọi API.
Các lớp (theo hook)
| 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 (+ authenticate cho token rác) | request.ip | 50/1s | ẩn danh + token không hợp lệ, trừ /assets | Chống abuse ẩn danh; token rác không né được limiter |
| Per-IP (assets) | onRequest | request.ip | 300/1s | ẩn danh, chỉ /assets/* | Media public: một viewer seek video bắn nhiều Range request/giây, một NAT dùng chung IP — budget riêng để không 429 giữa lúc phát |
| Per-principal: service | preHandler | userId | 10000/60s | role có service_account | Budget riêng cho từng service (BFF, job) |
| Per-principal: user | preHandler | userId | 300/60s | user thường | Giới hạn công bằng/chống abuse theo từng danh tính |
| Login | preHandler của POST /auth/login | request.ip | 10/60s (RATE_LIMITER_LOGIN_*) | mọi lượt login | Chống brute-force credential theo IP client thật |
Per-IP bỏ qua request có token ở
onRequest— lúc đó chưa biết token thật hay giả.rateLimiterIPreturn sớm khi córequest.token; sauauthenticate, nếu token không khớp strategy nào,authenticategọiconsumeIpBudget(request, reply)để trừ đúng budget Per-IP rồi mớithrow InvalidTokenError. Token thật nhưng hết hạn / bị revoke (sub-token, JWT thiếusid) không bị tính — đó không phải token rác.
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 principal, không theo IP, cho traffic đã auth. Per-IP limiter bị bỏ qua khi có token; per-principal 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. LuồngappApicủa BFF (một service token chung) đi tierservice— cần gán flagservice_accountcho role của user service đó, nếu không toàn bộ traffic public sẽ gộp vào một xô 300/60s. - IP client thật cho traffic ẩn danh / login / token rác. BFF forward IP client quan sát được qua
X-Forwarded-For, và API suy rarequest.iptừ đó theoTRUST_PROXY. Mặc định là danh sách địa chỉ proxy tin cậy (127.0.0.1/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7— loopback + RFC1918 + ULA) thay vì số hop, nên đúng bất kể có bao nhiêu tầng proxy nội bộ và bất kể request đi qua BFF hay app gọi thẳng API. Deployment có CDN public đứng ngoài cùng thì nối thêm range của CDN; muốn siết chặt thì thay bằng subnet proxy cụ thể (vd10.0.1.0/24). Vẫn nhận số hop (1) nếu 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-principal limiter quản traffic đã auth (300/60s user, 10000/60s service account) | Budget công bằng theo danh tính, không giả mạo được, không phụ thuộc proxy; service token của BFF không gộp end-user vào một xô |
| 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 = danh sách CIDR proxy tin cậy | 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). details.limiter cho biết tầng nào: global, ip, assets, service, per-user, endpoint (login: route = auth_login), email.
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 (kể cả login/email) |
RATE_LIMITER_STORE | string | 'memory' | 'memory' hoặc 'redis' |
TRUST_PROXY | CIDR list / số hop / true / false | 127.0.0.1/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7 | Proxy tin cậy để suy request.ip từ X-Forwarded-For. Mặc định tin theo địa chỉ (loopback + RFC1918 + ULA); có CDN public thì nối thêm range CDN |
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_ASSETS_POINTS | number | 300 | Per-IP cho /assets/* (ẩn danh): số request tối đa mỗi window |
RATE_LIMITER_ASSETS_DURATION | number | 1 | Per-IP assets: window (giây) |
RATE_LIMITER_USER_POINTS | number | 300 | Per-principal (user thường): số request tối đa mỗi window |
RATE_LIMITER_USER_DURATION | number | 60 | Per-principal (user thường): window (giây) |
RATE_LIMITER_SERVICE_POINTS | number | 10000 | Per-principal (role có service_account): số request tối đa mỗi window, mỗi service account một xô |
RATE_LIMITER_SERVICE_DURATION | number | 60 | Per-principal (service account): window (giây) |
RATE_LIMITER_LOGIN_POINTS | number | 10 | POST /auth/login: số lượt mỗi IP mỗi window |
RATE_LIMITER_LOGIN_DURATION | number | 60 | Login: 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-principal.ts | rateLimiterPrincipal — preHandler hook chọn tier service / user theo accountability.service_account, key = accountability.user; ẩn danh bỏ qua |
src/security/rate-limit/per-endpoint.ts | createEndpointLimiter — limiter theo route (login dùng auth_login với RATE_LIMITER_LOGIN_*) |
src/middleware/rate-limiter.ts | Global + per-IP onRequest hook; consumeIpBudget (dùng chung cho per-IP và token rác; /assets/* dùng budget assets riêng qua isAssetRequest), email limiter |
src/middleware/authenticate.ts | Set accountability.service_account từ role; token không khớp strategy nào → consumeIpBudget rồi 401 |
src/middleware/extract-token.ts | Set request.token (chạy trước per-IP, bật cơ chế skip) |
src/database/migrations/085-role-service-account.ts | Cột odp_roles.service_account |
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.