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 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

PrincipalKeyTier (env)Mặc định
Service accountaccountability.service_account === true (role có flag)accountability.user (mỗi service account một xô)RATE_LIMITER_SERVICE_POINTS / _DURATION10000/60s
User thường — có accountability.user, role không có flagaccountability.userRATE_LIMITER_USER_POINTS / _DURATION300/60s
Ẩn danh — không tokenrequest.ipRATE_LIMITER_POINTS / _DURATION50/1s (đã trừ ở onRequest)
Token rác — có token nhưng mọi strategy failrequest.ipnhư ẩn danhtrừ 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ớpHookKeyMặc địnhÁp dụng choMục đích
GlobalonRequest'global'1000/1stất cảBackstop chống DoS cho tổng throughput
Per-IPonRequest (+ authenticate cho token rác)request.ip50/1sẩn danh + token không hợp lệ, trừ /assetsChống abuse ẩn danh; token rác không né được limiter
Per-IP (assets)onRequestrequest.ip300/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: servicepreHandleruserId10000/60srole có service_accountBudget riêng cho từng service (BFF, job)
Per-principal: userpreHandleruserId300/60suser thườngGiới hạn công bằng/chống abuse theo từng danh tính
LoginpreHandler của POST /auth/loginrequest.ip10/60s (RATE_LIMITER_LOGIN_*)mọi lượt loginChố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ả. rateLimiterIP return sớm khi có request.token; sau authenticate, nếu token không khớp strategy nào, authenticate gọi consumeIpBudget(request, reply) để trừ đúng budget Per-IP rồi mới throw InvalidTokenError. Token thật nhưng hết hạn / bị revoke (sub-token, JWT thiếu sid) 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:

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 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ồng appApi của BFF (một service token chung) đi tier service — cần gán flag service_account cho role của user service đó, nếu không toàn bộ traffic public sẽ gộp vào một xô 300/60s.
  2. 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 ra request.ip từ đó theo TRUST_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ể (vd 10.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-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-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ậyGiớ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ếnKiểuMặc địnhMô tả
RATE_LIMITER_ENABLEDbooleantrueBật/tắt tổng cho mọi limiter (kể cả login/email)
RATE_LIMITER_STOREstring'memory''memory' hoặc 'redis'
TRUST_PROXYCIDR list / số hop / true / false127.0.0.1/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7Proxy 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_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_ASSETS_POINTSnumber300Per-IP cho /assets/* (ẩn danh): số request tối đa mỗi window
RATE_LIMITER_ASSETS_DURATIONnumber1Per-IP assets: window (giây)
RATE_LIMITER_USER_POINTSnumber300Per-principal (user thường): số request tối đa mỗi window
RATE_LIMITER_USER_DURATIONnumber60Per-principal (user thường): window (giây)
RATE_LIMITER_SERVICE_POINTSnumber10000Per-principal (role có service_account): số request tối đa mỗi window, mỗi service account một xô
RATE_LIMITER_SERVICE_DURATIONnumber60Per-principal (service account): window (giây)
RATE_LIMITER_LOGIN_POINTSnumber10POST /auth/login: số lượt mỗi IP mỗi window
RATE_LIMITER_LOGIN_DURATIONnumber60Login: 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-principal.tsrateLimiterPrincipalpreHandler hook chọn tier service / user theo accountability.service_account, key = accountability.user; ẩn danh bỏ qua
src/security/rate-limit/per-endpoint.tscreateEndpointLimiter — limiter theo route (login dùng auth_login với RATE_LIMITER_LOGIN_*)
src/middleware/rate-limiter.tsGlobal + 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.tsSet accountability.service_account từ role; token không khớp strategy nào → consumeIpBudget rồi 401
src/middleware/extract-token.tsSet request.token (chạy trước per-IP, bật cơ chế skip)
src/database/migrations/085-role-service-account.tsCột odp_roles.service_account
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