|
| 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