Skip to content

Commit e4724c6

Browse files
authored
Merge pull request #267 from yusuftomilola/feat/adaptive-rate-limiting-243
feat(security): add AdaptiveRateLimiterService with AI-based threat detection
2 parents a364cb1 + d6613d2 commit e4724c6

1 file changed

Lines changed: 321 additions & 0 deletions

File tree

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
3+
// ── Types ─────────────────────────────────────────────────────────────────────
4+
5+
export type ThreatLevel = 'none' | 'low' | 'medium' | 'high' | 'critical';
6+
7+
export type ApiKeyTier = 'free' | 'basic' | 'premium' | 'enterprise';
8+
9+
export interface RequestEvent {
10+
ip: string;
11+
userId?: string;
12+
apiKey?: string;
13+
endpoint: string;
14+
method: string;
15+
timestamp: number;
16+
responseTimeMs?: number;
17+
statusCode?: number;
18+
country?: string;
19+
}
20+
21+
export interface ThreatProfile {
22+
ip: string;
23+
userId?: string;
24+
trustScore: number; // 0 (untrusted) – 100 (fully trusted)
25+
threatLevel: ThreatLevel;
26+
requestsLastMinute: number;
27+
failureRate: number;
28+
anomalyScore: number;
29+
blockedUntil?: number;
30+
flags: string[];
31+
}
32+
33+
export interface RateLimitDecision {
34+
allowed: boolean;
35+
limit: number;
36+
remaining: number;
37+
resetAtMs: number;
38+
threatLevel: ThreatLevel;
39+
retryAfterMs?: number;
40+
reason?: string;
41+
}
42+
43+
export interface GeoRateLimit {
44+
country: string;
45+
requestsPerMinute: number;
46+
blocked: boolean;
47+
}
48+
49+
// ── Constants ─────────────────────────────────────────────────────────────────
50+
51+
const TIER_LIMITS: Record<ApiKeyTier, number> = {
52+
free: 60,
53+
basic: 300,
54+
premium: 1000,
55+
enterprise: 5000,
56+
};
57+
58+
const THREAT_MULTIPLIERS: Record<ThreatLevel, number> = {
59+
none: 1.0,
60+
low: 0.75,
61+
medium: 0.5,
62+
high: 0.25,
63+
critical: 0,
64+
};
65+
66+
// ── Service ───────────────────────────────────────────────────────────────────
67+
68+
/**
69+
* AdaptiveRateLimiterService
70+
*
71+
* Extends basic rate limiting with behavioural anomaly detection,
72+
* per-client trust scores, API key tier management, geographic
73+
* rate limiting, and real-time threat intelligence.
74+
*/
75+
@Injectable()
76+
export class AdaptiveRateLimiterService {
77+
private readonly logger = new Logger(AdaptiveRateLimiterService.name);
78+
79+
/** Rolling 1-minute request windows per IP */
80+
private readonly requestWindows = new Map<string, number[]>();
81+
/** Threat profiles per IP */
82+
private readonly threatProfiles = new Map<string, ThreatProfile>();
83+
/** Geographic rate limit overrides */
84+
private readonly geoLimits = new Map<string, GeoRateLimit>();
85+
/** API key → tier mapping */
86+
private readonly apiKeyTiers = new Map<string, ApiKeyTier>();
87+
88+
private readonly WINDOW_MS = 60_000;
89+
private readonly ANOMALY_BURST_THRESHOLD = 3; // × baseline → anomaly
90+
91+
// ── Main decision ─────────────────────────────────────────────────────────
92+
93+
/**
94+
* Evaluate a request and return an allow/deny decision with adaptive limits.
95+
*/
96+
evaluate(event: RequestEvent): RateLimitDecision {
97+
const key = this.buildKey(event);
98+
this.recordRequest(key, event);
99+
100+
const profile = this.getOrCreateProfile(event.ip, event.userId);
101+
this.updateBehaviourProfile(profile, event);
102+
103+
// Check hard block
104+
if (profile.blockedUntil && Date.now() < profile.blockedUntil) {
105+
return {
106+
allowed: false,
107+
limit: 0,
108+
remaining: 0,
109+
resetAtMs: profile.blockedUntil,
110+
threatLevel: profile.threatLevel,
111+
retryAfterMs: profile.blockedUntil - Date.now(),
112+
reason: 'IP temporarily blocked due to threat activity',
113+
};
114+
}
115+
116+
// Geographic check
117+
if (event.country) {
118+
const geoLimit = this.geoLimits.get(event.country.toUpperCase());
119+
if (geoLimit?.blocked) {
120+
return {
121+
allowed: false,
122+
limit: 0,
123+
remaining: 0,
124+
resetAtMs: Date.now() + this.WINDOW_MS,
125+
threatLevel: 'critical',
126+
reason: `Requests from ${event.country} are currently blocked`,
127+
};
128+
}
129+
}
130+
131+
// Determine effective limit
132+
const tier = event.apiKey ? (this.apiKeyTiers.get(event.apiKey) ?? 'free') : 'free';
133+
const baseLimit = TIER_LIMITS[tier];
134+
const multiplier = THREAT_MULTIPLIERS[profile.threatLevel];
135+
const effectiveLimit = Math.floor(baseLimit * multiplier);
136+
137+
const windowRequests = this.countWindowRequests(key);
138+
const remaining = Math.max(0, effectiveLimit - windowRequests);
139+
const resetAtMs = Date.now() + this.WINDOW_MS;
140+
141+
const allowed = effectiveLimit > 0 && windowRequests <= effectiveLimit;
142+
143+
if (!allowed) {
144+
this.logger.warn(
145+
`Rate limit exceeded for ${key} (tier=${tier}, threat=${profile.threatLevel}, reqs=${windowRequests}/${effectiveLimit})`,
146+
);
147+
}
148+
149+
return {
150+
allowed,
151+
limit: effectiveLimit,
152+
remaining,
153+
resetAtMs,
154+
threatLevel: profile.threatLevel,
155+
retryAfterMs: allowed ? undefined : this.WINDOW_MS,
156+
};
157+
}
158+
159+
// ── Threat / behaviour analysis ───────────────────────────────────────────
160+
161+
/**
162+
* Update the behavioural threat profile for an IP based on request patterns.
163+
*/
164+
private updateBehaviourProfile(profile: ThreatProfile, event: RequestEvent): void {
165+
const key = profile.ip;
166+
const now = Date.now();
167+
const windowRequests = this.countWindowRequests(key);
168+
169+
profile.requestsLastMinute = windowRequests;
170+
171+
// Anomaly: sudden burst beyond expected baseline
172+
const tier = event.apiKey ? (this.apiKeyTiers.get(event.apiKey) ?? 'free') : 'free';
173+
const baseline = TIER_LIMITS[tier] / 10; // expected per-10s average
174+
if (windowRequests > baseline * this.ANOMALY_BURST_THRESHOLD) {
175+
profile.anomalyScore = Math.min(100, profile.anomalyScore + 10);
176+
if (!profile.flags.includes('burst')) profile.flags.push('burst');
177+
}
178+
179+
// Failure rate analysis
180+
if (event.statusCode && event.statusCode >= 400) {
181+
profile.failureRate = Math.min(1, profile.failureRate + 0.05);
182+
if (profile.failureRate > 0.5 && !profile.flags.includes('high_failure')) {
183+
profile.flags.push('high_failure');
184+
}
185+
} else {
186+
profile.failureRate = Math.max(0, profile.failureRate - 0.01);
187+
}
188+
189+
// Derive threat level from anomaly score
190+
profile.threatLevel = this.scoreToThreatLevel(profile.anomalyScore, profile.failureRate);
191+
192+
// Auto-block critical threats for 15 minutes
193+
if (profile.threatLevel === 'critical' && !profile.blockedUntil) {
194+
profile.blockedUntil = now + 15 * 60_000;
195+
this.logger.error(`Auto-blocked ${profile.ip} — critical threat detected`);
196+
}
197+
198+
// Trust score decays with anomaly activity
199+
profile.trustScore = Math.max(0, 100 - profile.anomalyScore);
200+
201+
this.threatProfiles.set(key, profile);
202+
}
203+
204+
private scoreToThreatLevel(anomalyScore: number, failureRate: number): ThreatLevel {
205+
const combined = anomalyScore * 0.7 + failureRate * 100 * 0.3;
206+
if (combined >= 90) return 'critical';
207+
if (combined >= 70) return 'high';
208+
if (combined >= 40) return 'medium';
209+
if (combined >= 20) return 'low';
210+
return 'none';
211+
}
212+
213+
// ── API key tiers ─────────────────────────────────────────────────────────
214+
215+
/**
216+
* Register or update the tier for an API key.
217+
*/
218+
setApiKeyTier(apiKey: string, tier: ApiKeyTier): void {
219+
this.apiKeyTiers.set(apiKey, tier);
220+
this.logger.log(`API key tier set: ${apiKey.slice(0, 8)}… → ${tier}`);
221+
}
222+
223+
// ── Geographic controls ───────────────────────────────────────────────────
224+
225+
/**
226+
* Configure a per-country rate limit or block.
227+
*/
228+
setGeoLimit(country: string, requestsPerMinute: number, blocked = false): void {
229+
this.geoLimits.set(country.toUpperCase(), { country, requestsPerMinute, blocked });
230+
}
231+
232+
/**
233+
* Remove a geographic restriction.
234+
*/
235+
removeGeoLimit(country: string): void {
236+
this.geoLimits.delete(country.toUpperCase());
237+
}
238+
239+
// ── Manual moderation ─────────────────────────────────────────────────────
240+
241+
/**
242+
* Manually block an IP for a given duration.
243+
*/
244+
blockIp(ip: string, durationMs: number): void {
245+
const profile = this.getOrCreateProfile(ip);
246+
profile.blockedUntil = Date.now() + durationMs;
247+
profile.flags.push('manually_blocked');
248+
this.threatProfiles.set(ip, profile);
249+
this.logger.warn(`Manually blocked ${ip} for ${durationMs}ms`);
250+
}
251+
252+
/**
253+
* Clear a manual block and reset anomaly score for an IP.
254+
*/
255+
unblockIp(ip: string): void {
256+
const profile = this.threatProfiles.get(ip);
257+
if (profile) {
258+
profile.blockedUntil = undefined;
259+
profile.anomalyScore = 0;
260+
profile.threatLevel = 'none';
261+
profile.flags = profile.flags.filter((f) => f !== 'manually_blocked');
262+
this.threatProfiles.set(ip, profile);
263+
}
264+
}
265+
266+
/**
267+
* Get the current threat profile for an IP.
268+
*/
269+
getThreatProfile(ip: string): ThreatProfile | undefined {
270+
return this.threatProfiles.get(ip);
271+
}
272+
273+
/**
274+
* List all IPs currently flagged at or above a given threat level.
275+
*/
276+
getThreats(minLevel: ThreatLevel = 'medium'): ThreatProfile[] {
277+
const order: ThreatLevel[] = ['none', 'low', 'medium', 'high', 'critical'];
278+
const minIdx = order.indexOf(minLevel);
279+
return Array.from(this.threatProfiles.values()).filter(
280+
(p) => order.indexOf(p.threatLevel) >= minIdx,
281+
);
282+
}
283+
284+
// ── Helpers ───────────────────────────────────────────────────────────────
285+
286+
private buildKey(event: RequestEvent): string {
287+
return event.userId ?? event.apiKey ?? event.ip;
288+
}
289+
290+
private recordRequest(key: string, _event: RequestEvent): void {
291+
const now = Date.now();
292+
const window = this.requestWindows.get(key) ?? [];
293+
window.push(now);
294+
// Prune entries outside the rolling window
295+
const cutoff = now - this.WINDOW_MS;
296+
const trimmed = window.filter((t) => t > cutoff);
297+
this.requestWindows.set(key, trimmed);
298+
}
299+
300+
private countWindowRequests(key: string): number {
301+
const now = Date.now();
302+
const cutoff = now - this.WINDOW_MS;
303+
return (this.requestWindows.get(key) ?? []).filter((t) => t > cutoff).length;
304+
}
305+
306+
private getOrCreateProfile(ip: string, userId?: string): ThreatProfile {
307+
if (!this.threatProfiles.has(ip)) {
308+
this.threatProfiles.set(ip, {
309+
ip,
310+
userId,
311+
trustScore: 100,
312+
threatLevel: 'none',
313+
requestsLastMinute: 0,
314+
failureRate: 0,
315+
anomalyScore: 0,
316+
flags: [],
317+
});
318+
}
319+
return this.threatProfiles.get(ip)!;
320+
}
321+
}

0 commit comments

Comments
 (0)