Skip to content

Commit 9500808

Browse files
committed
feat: Redis-backed IP/wallet-aware rate limiting (#61)
- Add WalletAwareThrottlerGuard: keys by wallet address (JWT) with IP fallback - Add RedisThrottlerStorage: sliding window via sorted sets, no new deps - Wire globally via APP_GUARD in AppModule with ThrottlerModule.forRootAsync - Tighten per-route limits on expensive endpoints (quote simulation, uploads, auth) - Set Retry-After header on all 429 responses - Structured WARN log on every throttle hit for ops alerting - Add docs/rate-limiting.md with default limits and CDN/WAF guidance - Mark legacy in-memory rateLimit.ts as superseded
1 parent 6dfa5c5 commit 9500808

6 files changed

Lines changed: 266 additions & 62 deletions

File tree

backend/docs/rate-limiting.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Rate Limiting
2+
3+
All API endpoints are protected by a Redis-backed sliding-window rate limiter.
4+
Limits are applied **per wallet address** when a valid JWT is present, or **per IP address** otherwise.
5+
This means authenticated users behind a shared corporate NAT are not penalised collectively.
6+
7+
## Default Limits
8+
9+
| Endpoint group | Limit | Window |
10+
|---|---|---|
11+
| All endpoints (global default) | 120 requests | 60 seconds |
12+
| `POST /api/auth/challenge` | 20 requests | 5 minutes |
13+
| `POST /api/auth/verify` | 20 requests | 5 minutes |
14+
| `POST /api/quote/generate-premium` | 20 requests | 60 seconds |
15+
| `POST /api/ipfs/upload` | 10 requests | 60 seconds |
16+
| `POST /api/claims/build-transaction` | 10 requests | 60 seconds |
17+
18+
> These are approximate values and may be tuned based on observed traffic patterns.
19+
> Limits for claim submission (`POST /api/claims/submit`) are additionally governed by
20+
> a per-policy ledger-window counter (see claim rate limiting docs).
21+
22+
## Response Headers
23+
24+
Every response includes:
25+
26+
```
27+
X-RateLimit-Limit: <limit>
28+
X-RateLimit-Remaining: <remaining>
29+
X-RateLimit-Reset: <unix timestamp>
30+
```
31+
32+
When a limit is exceeded, the API returns **HTTP 429 Too Many Requests** with:
33+
34+
```
35+
Retry-After: <seconds until window resets>
36+
```
37+
38+
```json
39+
{
40+
"statusCode": 429,
41+
"message": "ThrottlerException: Too Many Requests"
42+
}
43+
```
44+
45+
## CDN / WAF Coordination
46+
47+
If a CDN or WAF sits in front of the API, ensure it forwards the real client IP via
48+
`X-Forwarded-For`. The backend trusts the first value in that header.
49+
Avoid double-counting at the CDN layer for the same limits — either enforce at CDN
50+
**or** at the API, not both, to prevent false positives for large NATs.
51+
52+
## Ops Monitoring
53+
54+
Throttle hits are logged as structured `WARN` entries under the `ThrottleHit` logger:
55+
56+
```json
57+
{
58+
"level": "warn",
59+
"message": "Throttle limit hit",
60+
"tracker": "wallet:GABC...",
61+
"key": "default",
62+
"totalHits": 121,
63+
"limit": 120,
64+
"retryAfterSec": 42,
65+
"method": "POST",
66+
"path": "/api/quote/generate-premium"
67+
}
68+
```
69+
70+
Alert on sustained `ThrottleHit` volume from a single tracker to detect abuse patterns.

backend/src/app.module.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Module } from '@nestjs/common';
22
import { ConfigModule } from '@nestjs/config';
33
import { TerminusModule } from '@nestjs/terminus';
4-
import { ThrottlerModule } from '@nestjs/throttler';
4+
import { ThrottlerModule, ThrottlerStorage } from '@nestjs/throttler';
5+
import { APP_GUARD } from '@nestjs/core';
56
import { validationSchema } from './config/env.validation';
67
import { HealthModule } from './health/health.module';
78
import { PrismaModule } from './prisma/prisma.module';
@@ -19,6 +20,9 @@ import { TxModule } from './tx/tx.module';
1920
import { FeatureFlagsModule } from './feature-flags/feature-flags.module';
2021
import { OracleHooksController } from './experimental/oracle-hooks.controller';
2122
import { BetaCalculatorsController } from './experimental/beta-calculators.controller';
23+
import { WalletAwareThrottlerGuard } from './common/guards/throttler.guard';
24+
import { RedisThrottlerStorage } from './common/guards/throttler-redis.storage';
25+
import { RedisService } from './cache/redis.service';
2226

2327
@Module({
2428
imports: [
@@ -30,7 +34,17 @@ import { BetaCalculatorsController } from './experimental/beta-calculators.contr
3034
abortEarly: true,
3135
},
3236
}),
33-
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
37+
ThrottlerModule.forRootAsync({
38+
imports: [CacheModule],
39+
inject: [RedisService],
40+
useFactory: (redis: RedisService) => ({
41+
throttlers: [
42+
// Global default: 120 req / 60 s per identity (wallet or IP)
43+
{ name: 'default', ttl: 60_000, limit: 120 },
44+
],
45+
storage: new RedisThrottlerStorage(redis) as unknown as ThrottlerStorage,
46+
}),
47+
}),
3448
TerminusModule,
3549
PrismaModule,
3650
CacheModule,
@@ -48,5 +62,10 @@ import { BetaCalculatorsController } from './experimental/beta-calculators.contr
4862
FeatureFlagsModule,
4963
],
5064
controllers: [OracleHooksController, BetaCalculatorsController],
65+
providers: [
66+
// Apply WalletAwareThrottlerGuard globally — individual routes can
67+
// override limits with @Throttle({ default: { limit, ttl } })
68+
{ provide: APP_GUARD, useClass: WalletAwareThrottlerGuard },
69+
],
5170
})
5271
export class AppModule {}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { ThrottlerStorage } from '@nestjs/throttler';
3+
import { ThrottlerStorageRecord } from '@nestjs/throttler';
4+
import { RedisService } from '../cache/redis.service';
5+
6+
/**
7+
* Redis-backed storage for @nestjs/throttler.
8+
*
9+
* Keys: throttle:<throttlerName>:<tracker>
10+
* Each key is a sorted set of request timestamps (ms).
11+
* TTL is set to the window size so Redis auto-expires stale keys.
12+
*/
13+
@Injectable()
14+
export class RedisThrottlerStorage implements ThrottlerStorage {
15+
constructor(private readonly redis: RedisService) {}
16+
17+
async increment(
18+
key: string,
19+
ttl: number,
20+
limit: number,
21+
blockDuration: number,
22+
throttlerName: string,
23+
): Promise<ThrottlerStorageRecord> {
24+
const client = this.redis.getClient();
25+
const redisKey = `throttle:${throttlerName}:${key}`;
26+
const now = Date.now();
27+
const windowStart = now - ttl;
28+
const ttlSec = Math.ceil(ttl / 1000);
29+
30+
// Sliding window: remove timestamps outside the current window, add now
31+
const pipeline = client.pipeline();
32+
pipeline.zremrangebyscore(redisKey, '-inf', windowStart);
33+
pipeline.zadd(redisKey, now, `${now}-${Math.random()}`);
34+
pipeline.zcard(redisKey);
35+
pipeline.expire(redisKey, ttlSec);
36+
const results = await pipeline.exec();
37+
38+
// zcard result is at index 2
39+
const totalHits = (results?.[2]?.[1] as number) ?? 1;
40+
const isBlocked = totalHits > limit;
41+
42+
let timeToExpire = ttl;
43+
if (isBlocked && blockDuration > 0) {
44+
timeToExpire = blockDuration;
45+
}
46+
47+
return {
48+
totalHits,
49+
timeToExpire,
50+
isBlocked,
51+
timeToBlockExpire: isBlocked ? blockDuration : 0,
52+
};
53+
}
54+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { ThrottlerGuard, ThrottlerException } from '@nestjs/throttler';
3+
import { ExecutionContext } from '@nestjs/common';
4+
import { Request } from 'express';
5+
6+
/**
7+
* Extends NestJS ThrottlerGuard to:
8+
* - Key by wallet address (from JWT sub) when available, otherwise by IP.
9+
* This prevents large corporate NATs from being punished collectively.
10+
* - Set Retry-After header on 429 responses.
11+
* - Emit a structured log on every throttle hit for ops alerting.
12+
*/
13+
@Injectable()
14+
export class WalletAwareThrottlerGuard extends ThrottlerGuard {
15+
private readonly logger = new Logger('ThrottleHit');
16+
17+
protected async getTracker(req: Request): Promise<string> {
18+
// Prefer wallet identity from JWT payload (attached by JwtStrategy / passport)
19+
const user = (req as Request & { user?: { walletAddress?: string } }).user;
20+
if (user?.walletAddress) {
21+
return `wallet:${user.walletAddress}`;
22+
}
23+
24+
// Fall back to IP — honour X-Forwarded-For set by trusted proxy/CDN
25+
const forwarded = req.headers['x-forwarded-for'];
26+
const ip =
27+
(typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : null) ??
28+
req.socket?.remoteAddress ??
29+
'unknown';
30+
31+
return `ip:${ip}`;
32+
}
33+
34+
protected async throwThrottlingException(
35+
context: ExecutionContext,
36+
throttlerLimitDetail: { ttl: number; limit: number; key: string; tracker: string; totalHits: number },
37+
): Promise<void> {
38+
const req = context.switchToHttp().getRequest<Request>();
39+
const res = context.switchToHttp().getResponse<{ setHeader: (k: string, v: string | number) => void }>();
40+
41+
const retryAfterSec = Math.ceil(throttlerLimitDetail.ttl / 1000);
42+
res.setHeader('Retry-After', retryAfterSec);
43+
44+
// Structured log for ops dashboards / alerting
45+
this.logger.warn('Throttle limit hit', {
46+
tracker: throttlerLimitDetail.tracker,
47+
key: throttlerLimitDetail.key,
48+
totalHits: throttlerLimitDetail.totalHits,
49+
limit: throttlerLimitDetail.limit,
50+
retryAfterSec,
51+
method: req.method,
52+
path: req.path,
53+
});
54+
55+
throw new ThrottlerException();
56+
}
57+
}
Lines changed: 61 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,61 @@
1-
/**
2-
* Simple in-memory rate limiter — no external dependencies.
3-
*
4-
* Strategy: sliding window per IP.
5-
* Limit: 60 requests / 60 seconds for public policy endpoints.
6-
*
7-
* For production, replace with a Redis-backed solution (e.g. rate-limiter-flexible).
8-
*/
9-
10-
import { Request, Response, NextFunction } from "express";
11-
12-
interface WindowEntry {
13-
count: number;
14-
windowStart: number;
15-
}
16-
17-
const store = new Map<string, WindowEntry>();
18-
const WINDOW_MS = 60_000; // 1 minute
19-
const MAX_REQUESTS = 60;
20-
21-
function getClientIp(req: Request): string {
22-
const forwarded = req.headers["x-forwarded-for"];
23-
if (typeof forwarded === "string") return forwarded.split(",")[0].trim();
24-
return req.socket.remoteAddress ?? "unknown";
25-
}
26-
27-
export function publicRateLimit(
28-
req: Request,
29-
res: Response,
30-
next: NextFunction
31-
): void {
32-
const ip = getClientIp(req);
33-
const now = Date.now();
34-
const entry = store.get(ip);
35-
36-
if (!entry || now - entry.windowStart > WINDOW_MS) {
37-
store.set(ip, { count: 1, windowStart: now });
38-
res.setHeader("X-RateLimit-Limit", MAX_REQUESTS);
39-
res.setHeader("X-RateLimit-Remaining", MAX_REQUESTS - 1);
40-
next();
41-
return;
42-
}
43-
44-
entry.count += 1;
45-
const remaining = Math.max(0, MAX_REQUESTS - entry.count);
46-
res.setHeader("X-RateLimit-Limit", MAX_REQUESTS);
47-
res.setHeader("X-RateLimit-Remaining", remaining);
48-
49-
if (entry.count > MAX_REQUESTS) {
50-
const retryAfter = Math.ceil((WINDOW_MS - (now - entry.windowStart)) / 1000);
51-
res.setHeader("Retry-After", retryAfter);
52-
res.status(429).json({
53-
error: "rate_limit_exceeded",
54-
message: `Too many requests. Retry after ${retryAfter}s.`,
55-
});
56-
return;
57-
}
58-
59-
next();
60-
}
1+
/**
2+
* Legacy in-memory rate limiter — kept for reference only.
3+
*
4+
* The active rate limiting is handled by WalletAwareThrottlerGuard (Redis-backed)
5+
* registered globally in AppModule via APP_GUARD. Per-route overrides use
6+
* @Throttle({ default: { limit, ttl } }) on individual controller methods.
7+
*
8+
* This file is NOT wired into the application. Do not import it.
9+
*/
10+
11+
import { Request, Response, NextFunction } from "express";
12+
13+
interface WindowEntry {
14+
count: number;
15+
windowStart: number;
16+
}
17+
18+
const store = new Map<string, WindowEntry>();
19+
const WINDOW_MS = 60_000;
20+
const MAX_REQUESTS = 60;
21+
22+
function getClientIp(req: Request): string {
23+
const forwarded = req.headers["x-forwarded-for"];
24+
if (typeof forwarded === "string") return forwarded.split(",")[0].trim();
25+
return req.socket.remoteAddress ?? "unknown";
26+
}
27+
28+
export function publicRateLimit(
29+
req: Request,
30+
res: Response,
31+
next: NextFunction
32+
): void {
33+
const ip = getClientIp(req);
34+
const now = Date.now();
35+
const entry = store.get(ip);
36+
37+
if (!entry || now - entry.windowStart > WINDOW_MS) {
38+
store.set(ip, { count: 1, windowStart: now });
39+
res.setHeader("X-RateLimit-Limit", MAX_REQUESTS);
40+
res.setHeader("X-RateLimit-Remaining", MAX_REQUESTS - 1);
41+
next();
42+
return;
43+
}
44+
45+
entry.count += 1;
46+
const remaining = Math.max(0, MAX_REQUESTS - entry.count);
47+
res.setHeader("X-RateLimit-Limit", MAX_REQUESTS);
48+
res.setHeader("X-RateLimit-Remaining", remaining);
49+
50+
if (entry.count > MAX_REQUESTS) {
51+
const retryAfter = Math.ceil((WINDOW_MS - (now - entry.windowStart)) / 1000);
52+
res.setHeader("Retry-After", retryAfter);
53+
res.status(429).json({
54+
error: "rate_limit_exceeded",
55+
message: `Too many requests. Retry after ${retryAfter}s.`,
56+
});
57+
return;
58+
}
59+
60+
next();
61+
}

backend/src/quote/quote.controller.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
22
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
3+
import { Throttle } from '@nestjs/throttler';
34
import { QuoteService } from './quote.service';
45
import { GeneratePremiumDto } from './dto/generate-premium.dto';
56

@@ -17,6 +18,8 @@ export class QuoteController {
1718
*/
1819
@Post('generate-premium')
1920
@HttpCode(HttpStatus.OK)
21+
// Simulation is CPU/RPC-expensive: 20 req / 60 s per identity
22+
@Throttle({ default: { limit: 20, ttl: 60_000 } })
2023
@ApiOperation({ summary: 'Simulate annual premium for a proposed policy' })
2124
@ApiResponse({ status: 200, description: 'Premium quote' })
2225
@ApiResponse({ status: 400, description: 'Validation error or account not found' })

0 commit comments

Comments
 (0)