Rate Limiting 要防的三件事

暴力破解:攻擊者對 /auth/login 每秒打幾千次,嘗試密碼組合。沒有限流就是讓他無限試。

API 濫用 / Scraping:競爭對手或爬蟲把你的 product catalog、用戶資料一頁頁抽走。

失控的 client:自家的 mobile app 因為 bug 進入 retry loop,把 server 壓垮。這比外部攻擊更常見。


三種限流演算法

Fixed Window(最簡單,但有邊界問題)

每分鐘最多 100 次請求
00:00–01:00 窗口:已用 99 次
00:59 打第 100 次(在窗口末尾)→ 通過
01:00–02:00 新窗口重置:打第 1 次 → 通過

問題:59 秒和 61 秒之間(跨窗口邊界)可以打 200 次——兩個窗口各消耗完。

Sliding Window(實務最常用)

任意 1 分鐘滾動窗口內最多 100 次

用 Redis sorted set 實作:每次請求記錄 timestamp,查詢「過去 60 秒內的 request 數」。

Token Bucket(允許突發流量)

桶容量 100,每秒補充 10 個 token
平時可以突發打 100 次(消耗整個桶)
之後限速到每秒 10 次

適合允許短暫突發的場景(瀏覽器批次請求)。


Redis Sliding Window 實作

import { Redis } from 'ioredis';
 
const redis = new Redis(process.env.REDIS_URL);
 
interface RateLimitOptions {
  windowMs: number;    // 窗口大小(毫秒)
  maxRequests: number; // 窗口內最大請求數
  keyPrefix: string;
}
 
async function isRateLimited(
  identifier: string,
  options: RateLimitOptions
): Promise<{ limited: boolean; remaining: number; resetAt: number }> {
  const now = Date.now();
  const windowStart = now - options.windowMs;
  const key = `${options.keyPrefix}:${identifier}`;
 
  const pipeline = redis.pipeline();
  // 刪除窗口外的舊記錄
  pipeline.zremrangebyscore(key, 0, windowStart);
  // 計算窗口內請求數
  pipeline.zcard(key);
  // 記錄本次請求
  pipeline.zadd(key, now, `${now}-${Math.random()}`);
  // TTL 設為窗口大小
  pipeline.expire(key, Math.ceil(options.windowMs / 1000));
 
  const results = await pipeline.exec();
  const currentCount = (results[1][1] as number) + 1;  // +1 包含本次
 
  return {
    limited: currentCount > options.maxRequests,
    remaining: Math.max(0, options.maxRequests - currentCount),
    resetAt: now + options.windowMs,
  };
}
 
// Express middleware
export const rateLimiter = (options: RateLimitOptions) => async (req, res, next) => {
  // 未登入用 IP,已登入用 user ID(更精準)
  const identifier = req.user?.id ?? req.ip;
  const result = await isRateLimited(identifier, options);
 
  // 標準 rate limit headers
  res.set('X-RateLimit-Limit', options.maxRequests.toString());
  res.set('X-RateLimit-Remaining', result.remaining.toString());
  res.set('X-RateLimit-Reset', Math.ceil(result.resetAt / 1000).toString());
 
  if (result.limited) {
    res.set('Retry-After', Math.ceil(options.windowMs / 1000).toString());
    return res.status(429).json({ error: 'Too Many Requests' });
  }
  next();
};

Per-IP vs Per-User vs Per-API-Key

這三個的適用場景不同:

策略適合問題
Per-IP未登入的公開 endpoint(登入、註冊)NAT 後多用戶共用 IP,會誤傷正常用戶
Per-User已登入的 API需要能取得 user ID
Per-API-Key第三方開發者 API需要 API key 管理機制

不同 endpoint 的策略

// 登入 endpoint:per-IP,嚴格(防暴力破解)
app.post('/auth/login',
  rateLimiter({ windowMs: 15 * 60 * 1000, maxRequests: 10, keyPrefix: 'login-ip' }),
  authController.login
);
 
// 一般 API:先 authenticate,再 per-user 限流
app.use('/api',
  rateLimiter({ windowMs: 60 * 1000, maxRequests: 300, keyPrefix: 'api-ip' }),  // IP 粗限
  authenticate,
  rateLimiter({ windowMs: 60 * 1000, maxRequests: 100, keyPrefix: 'api-user' }),  // user 細限
  apiRouter
);
 
// 匯出 / 重操作:獨立限流
app.get('/api/reports/export',
  authenticate,
  rateLimiter({ windowMs: 60 * 60 * 1000, maxRequests: 5, keyPrefix: 'export' }),
  reportController.export
);

放在哪一層

Rate limiter 要在 auth 驗證之前(對公開 endpoint)。

原因:auth token 驗證本身有成本(DB 查詢或 JWT 解碼)。如果攻擊者每秒打 1000 次 /auth/login,你不想先做 1000 次 JWT verify 才限流——先限流,把不合格的請求在 middleware 最前面擋掉。

// ✅ 正確順序
app.use(helmet());
app.use(cors(corsOptions));
app.use(express.json());
app.use(generalRateLimiter);   // ← rate limit 在 authenticate 之前
app.use(authenticate);          // ← 驗 token(有 DB/Redis 成本)
app.use(auditLogger);
app.use('/api', router);

這和 Middleware 模型 裡的掛載順序原則一致:貴的操作往後放,能早擋的早擋。


各框架的 Rate Limiting 工具

框架工具Redis 支援
Expressexpress-rate-limit + rate-limit-redis
FastAPIslowapi(基於 limits library)
NestJS@nestjs/throttler + Redis store
Spring Bootbucket4j-spring-boot-starter
Laravelthrottle middleware(內建)+ Redis

在 K8s 環境裡,rate limiting 也可以放在 API Gateway / Ingress 層(Kong、NGINX、AWS API Gateway),但那是 infra 視角;應用層的 rate limiting 是縱深防禦,兩層都有比只有一層好。


延伸閱讀