148 lines
6.0 KiB
Markdown
148 lines
6.0 KiB
Markdown
# ADR-032: Rate Limiting Strategy
|
|
|
|
**Date**: 2026-01-09
|
|
|
|
**Status**: Accepted
|
|
|
|
**Implemented**: 2026-01-09
|
|
|
|
## Context
|
|
|
|
Public-facing APIs are vulnerable to abuse through excessive requests, whether from malicious actors attempting denial-of-service attacks, automated scrapers, or accidental loops in client code. Without proper rate limiting, the application could:
|
|
|
|
1. **Experience degraded performance**: Excessive requests can overwhelm database connections and server resources
|
|
2. **Incur unexpected costs**: AI service calls (Gemini API) and external APIs (Google Maps) are billed per request
|
|
3. **Allow credential stuffing**: Login endpoints without limits enable brute-force attacks
|
|
4. **Suffer from data scraping**: Public endpoints could be scraped at high volume
|
|
|
|
## Decision
|
|
|
|
We will implement a tiered rate limiting strategy using `express-rate-limit` middleware, with different limits based on endpoint sensitivity and resource cost.
|
|
|
|
### Tier System
|
|
|
|
| Tier | Window | Max Requests | Use Case |
|
|
| --------------------------- | ------ | ------------ | -------------------------------- |
|
|
| **Authentication (Strict)** | 15 min | 5 | Login, registration |
|
|
| **Sensitive Operations** | 1 hour | 5 | Password changes, email updates |
|
|
| **AI/Costly Operations** | 15 min | 10-20 | Gemini API calls, geocoding |
|
|
| **File Uploads** | 15 min | 10-20 | Flyer uploads, avatar uploads |
|
|
| **Batch Operations** | 15 min | 50 | Bulk updates |
|
|
| **User Read** | 15 min | 100 | Standard authenticated endpoints |
|
|
| **Public Read** | 15 min | 100 | Public data endpoints |
|
|
| **Tracking/High-Volume** | 15 min | 150-200 | Analytics, reactions |
|
|
|
|
### Rate Limiter Configuration
|
|
|
|
All rate limiters share a standard configuration:
|
|
|
|
```typescript
|
|
const standardConfig = {
|
|
standardHeaders: true, // Return rate limit info in headers
|
|
legacyHeaders: false, // Disable deprecated X-RateLimit headers
|
|
skip: shouldSkipRateLimit, // Allow bypassing in test environment
|
|
};
|
|
```
|
|
|
|
### Test Environment Bypass
|
|
|
|
Rate limiting is bypassed during integration and E2E tests to avoid test flakiness:
|
|
|
|
```typescript
|
|
export const shouldSkipRateLimit = (req: Request): boolean => {
|
|
return process.env.NODE_ENV === 'test';
|
|
};
|
|
```
|
|
|
|
## Implementation Details
|
|
|
|
### Available Rate Limiters
|
|
|
|
| Limiter | Window | Max | Endpoint Examples |
|
|
| ---------------------------- | ------ | --- | --------------------------------- |
|
|
| `loginLimiter` | 15 min | 5 | POST /api/auth/login |
|
|
| `registerLimiter` | 1 hour | 5 | POST /api/auth/register |
|
|
| `forgotPasswordLimiter` | 15 min | 5 | POST /api/auth/forgot-password |
|
|
| `resetPasswordLimiter` | 15 min | 10 | POST /api/auth/reset-password |
|
|
| `refreshTokenLimiter` | 15 min | 20 | POST /api/auth/refresh |
|
|
| `logoutLimiter` | 15 min | 10 | POST /api/auth/logout |
|
|
| `publicReadLimiter` | 15 min | 100 | GET /api/flyers, GET /api/recipes |
|
|
| `userReadLimiter` | 15 min | 100 | GET /api/users/profile |
|
|
| `userUpdateLimiter` | 15 min | 100 | PUT /api/users/profile |
|
|
| `userSensitiveUpdateLimiter` | 1 hour | 5 | PUT /api/auth/change-password |
|
|
| `adminTriggerLimiter` | 15 min | 30 | POST /api/admin/jobs/\* |
|
|
| `aiGenerationLimiter` | 15 min | 20 | POST /api/ai/analyze |
|
|
| `aiUploadLimiter` | 15 min | 10 | POST /api/ai/upload-and-process |
|
|
| `geocodeLimiter` | 1 hour | 100 | GET /api/users/geocode |
|
|
| `priceHistoryLimiter` | 15 min | 50 | GET /api/price-history/\* |
|
|
| `reactionToggleLimiter` | 15 min | 150 | POST /api/reactions/toggle |
|
|
| `trackingLimiter` | 15 min | 200 | POST /api/personalization/track |
|
|
| `batchLimiter` | 15 min | 50 | PATCH /api/budgets/batch |
|
|
|
|
### Usage Pattern
|
|
|
|
```typescript
|
|
import { loginLimiter, userReadLimiter } from '../config/rateLimiters';
|
|
|
|
// Apply to individual routes
|
|
router.post('/login', loginLimiter, validateRequest(loginSchema), async (req, res, next) => {
|
|
// handler
|
|
});
|
|
|
|
// Or apply to entire router for consistent limits
|
|
router.use(userReadLimiter);
|
|
router.get('/me', async (req, res, next) => {
|
|
/* handler */
|
|
});
|
|
```
|
|
|
|
### Response Headers
|
|
|
|
When rate limiting is active, responses include standard headers:
|
|
|
|
```
|
|
RateLimit-Limit: 100
|
|
RateLimit-Remaining: 95
|
|
RateLimit-Reset: 900
|
|
```
|
|
|
|
### Rate Limit Exceeded Response
|
|
|
|
When a client exceeds their limit:
|
|
|
|
```json
|
|
{
|
|
"message": "Too many login attempts from this IP, please try again after 15 minutes."
|
|
}
|
|
```
|
|
|
|
HTTP Status: `429 Too Many Requests`
|
|
|
|
## Key Files
|
|
|
|
- `src/config/rateLimiters.ts` - Rate limiter definitions
|
|
- `src/utils/rateLimit.ts` - Helper functions (test bypass)
|
|
|
|
## Consequences
|
|
|
|
### Positive
|
|
|
|
- **Security**: Protects against brute-force and credential stuffing attacks
|
|
- **Cost Control**: Prevents runaway costs from AI/external API abuse
|
|
- **Fair Usage**: Ensures all users get reasonable service access
|
|
- **DDoS Mitigation**: Provides basic protection against request flooding
|
|
|
|
### Negative
|
|
|
|
- **Legitimate User Impact**: Aggressive users may hit limits during normal use
|
|
- **IP-Based Limitations**: Shared IPs (offices, VPNs) may cause false positives
|
|
- **No Distributed State**: Rate limits are per-instance, not cluster-wide (would need Redis store for that)
|
|
|
|
## Future Enhancements
|
|
|
|
1. **Redis Store**: Implement distributed rate limiting with Redis for multi-instance deployments
|
|
2. **User-Based Limits**: Track limits per authenticated user rather than just IP
|
|
3. **Dynamic Limits**: Adjust limits based on user tier (free vs premium)
|
|
4. **Monitoring Dashboard**: Track rate limit hits in admin dashboard
|
|
5. **Allowlisting**: Allow specific IPs (monitoring services) to bypass limits
|