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 支援 |
|---|---|---|
| Express | express-rate-limit + rate-limit-redis | ✅ |
| FastAPI | slowapi(基於 limits library) | ✅ |
| NestJS | @nestjs/throttler + Redis store | ✅ |
| Spring Boot | bucket4j-spring-boot-starter | ✅ |
| Laravel | throttle middleware(內建)+ Redis | ✅ |
在 K8s 環境裡,rate limiting 也可以放在 API Gateway / Ingress 層(Kong、NGINX、AWS API Gateway),但那是 infra 視角;應用層的 rate limiting 是縱深防禦,兩層都有比只有一層好。
