UNPKG

@lock-dev/rate-limit

Version:

Rate limiter module for lock.dev security framework

1,291 lines (1,274 loc) 41.6 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { AdaptiveStrategy: () => AdaptiveStrategy, DDoSProtection: () => DDoSProtection, FixedWindowStrategy: () => FixedWindowStrategy, LeakyBucketStrategy: () => LeakyBucketStrategy, MemoryStore: () => MemoryStore, RateLimitEventType: () => RateLimitEventType, RedisStore: () => RedisStore, SlidingWindowStrategy: () => SlidingWindowStrategy, TokenBucketStrategy: () => TokenBucketStrategy, UpstashStore: () => UpstashStore, createStore: () => createStore, createStrategy: () => createStrategy, rateLimit: () => rateLimit }); module.exports = __toCommonJS(index_exports); var import_core = require("@lock-dev/core"); // src/types.ts var RateLimitEventType = /* @__PURE__ */ ((RateLimitEventType2) => { RateLimitEventType2["RATE_LIMITED"] = "rate_limited"; RateLimitEventType2["RATE_LIMIT_WARNING"] = "rate_limit_warning"; RateLimitEventType2["RATE_LIMIT_ADAPTIVE_ESCALATION"] = "rate_limit_adaptive_escalation"; RateLimitEventType2["DDOS_PROTECTION_TRIGGERED"] = "ddos_protection_triggered"; return RateLimitEventType2; })(RateLimitEventType || {}); // src/storage/memory.ts var import_lru_cache = require("lru-cache"); var MemoryStore = class { /** * Creates an instance of MemoryStore. * * @param {Object} options - Configuration options for the store. * @param {number} options.max - Maximum number of items the cache can hold. * @param {number} options.ttl - Time-to-live for each cache entry in milliseconds. */ constructor(options) { this.cache = new import_lru_cache.LRUCache({ max: options.max || 1e4, ttl: options.ttl || 36e5, ttlAutopurge: true }); } /** * Initializes the MemoryStore. * This method is called to set up the store and can be used to perform any asynchronous initialization tasks. * * @returns {Promise<void>} A promise that resolves when the store is initialized. */ async init() { console.log("MemoryStore initialized"); return Promise.resolve(); } /** * Retrieves a rate limit record from the store for the given key. * * @param {string} key - The key identifying the rate limit record. * @returns {Promise<RateLimitRecord | null>} A promise that resolves with the record if found, or null if not. */ async get(key) { const record = this.cache.get(key) || null; console.log(`GET key=${key}:`, record ? `found (count=${record.count})` : "not found"); return record; } /** * Stores a rate limit record in the store under the given key. * * @param {string} key - The key under which to store the record. * @param {RateLimitRecord} value - The rate limit record to store. * @returns {Promise<void>} A promise that resolves once the record is stored. */ async set(key, value) { console.log(`SET key=${key}:`, value); this.cache.set(key, value); return Promise.resolve(); } /** * Increments the rate limit counter for a given key. * If the key does not exist, a new record is created. * The function updates the record's count, lastRequest timestamp, and history. * * @param {string} key - The key identifying the rate limit record. * @param {number} [value=1] - The value to increment the counter by. * @returns {Promise<RateLimitRecord>} A promise that resolves with the updated rate limit record. */ async increment(key, value = 1) { const now = Date.now(); let record = this.cache.get(key); if (!record) { record = { count: value, firstRequest: now, lastRequest: now, history: [now] }; } else { record.count += value; record.lastRequest = now; if (!record.history) { record.history = []; } record.history.push(now); } this.cache.set(key, record); return record; } /** * Resets the rate limit record for a given key by removing it from the store. * * @param {string} key - The key identifying the rate limit record to reset. * @returns {Promise<void>} A promise that resolves once the record is removed. */ async reset(key) { this.cache.delete(key); return Promise.resolve(); } /** * Closes the store. * This method can be used to perform any necessary cleanup operations. * * @returns {Promise<void>} A promise that resolves when the store is closed. */ async close() { return Promise.resolve(); } /** * Adds a specific timestamp to the history of requests for a given key. * If the record exists, the timestamp is appended to its history. * * @param {string} key - The key identifying the rate limit record. * @param {number} timestamp - The timestamp to add to the record's history. * @returns {Promise<void>} A promise that resolves once the timestamp is added. */ async addToHistory(key, timestamp) { const record = this.cache.get(key); if (record) { if (!record.history) { record.history = []; } record.history.push(timestamp); this.cache.set(key, record); } return Promise.resolve(); } /** * Retrieves the history of timestamps for a given key, filtered to include only those * entries that occurred after a specified start time. * * @param {string} key - The key identifying the rate limit record. * @param {number} startTime - The start time (inclusive) for filtering the history. * @returns {Promise<number[]>} A promise that resolves with an array of timestamps from the history that meet the criteria. */ async getWindowHistory(key, startTime) { const record = this.cache.get(key); const history = record?.history || []; const filtered = history.filter((time) => time >= startTime); return filtered; } }; // src/storage/redis.ts var RedisStore = class { constructor(config) { this.keyPrefix = config.redis?.keyPrefix || "ratelimit:"; this.config = config.redis; } async init() { try { const { createClient } = await import("redis"); if (this.config.url) { this.client = createClient({ url: this.config.url }); } else { this.client = createClient({ socket: { host: this.config.host || "localhost", port: this.config.port || 6379 }, username: this.config.username, password: this.config.password, database: this.config.database || 0 }); } await this.client.connect(); this.client.on("error", (err) => { console.error("Redis client error:", err); }); } catch (error) { console.error("Failed to initialize Redis client:", error); throw new Error("Redis initialization failed"); } } async get(key) { try { const data = await this.client.get(this.keyPrefix + key); return data ? JSON.parse(data) : null; } catch (error) { console.error("Redis get error:", error); return null; } } async set(key, value) { try { const ttl = Math.ceil((value.firstRequest + 36e5 - Date.now()) / 1e3); await this.client.set(this.keyPrefix + key, JSON.stringify(value), { EX: ttl > 0 ? ttl : 60 }); } catch (error) { console.error("Redis set error:", error); } } async increment(key, value = 1) { try { const fullKey = this.keyPrefix + key; const now = Date.now(); const exists = await this.client.exists(fullKey); if (!exists) { const record = { count: value, firstRequest: now, lastRequest: now, history: [now] }; await this.client.set(fullKey, JSON.stringify(record), { EX: 3600 }); return record; } else { const script = ` local record = cjson.decode(redis.call('get', KEYS[1])) record.count = record.count + ARGV[1] record.lastRequest = ARGV[2] if record.history then table.insert(record.history, ARGV[2]) else record.history = {ARGV[2]} end redis.call('set', KEYS[1], cjson.encode(record)) return cjson.encode(record) `; const result = await this.client.eval(script, { keys: [fullKey], arguments: [value.toString(), now.toString()] }); return JSON.parse(result); } } catch (error) { console.error("Redis increment error:", error); const record = await this.get(key) || { count: 0, firstRequest: Date.now(), lastRequest: Date.now(), history: [] }; record.count += value; record.lastRequest = Date.now(); if (record.history) { record.history.push(record.lastRequest); } else { record.history = [record.lastRequest]; } await this.set(key, record); return record; } } async reset(key) { try { await this.client.del(this.keyPrefix + key); await this.client.del(this.keyPrefix + key + ":history"); } catch (error) { console.error("Redis reset error:", error); } } async close() { try { if (this.client) { await this.client.quit(); } } catch (error) { console.error("Redis close error:", error); } } async addToHistory(key, timestamp) { try { const historyKey = this.keyPrefix + key + ":history"; await this.client.lPush(historyKey, timestamp.toString()); await this.client.expire(historyKey, 3600); } catch (error) { console.error("Redis addToHistory error:", error); } } async getWindowHistory(key, startTime) { try { const historyKey = this.keyPrefix + key + ":history"; const allItems = await this.client.lRange(historyKey, 0, -1); return allItems.map((item) => parseInt(item, 10)).filter((time) => time >= startTime); } catch (error) { console.error("Redis getWindowHistory error:", error); return []; } } }; // src/storage/upstash.ts var UpstashStore = class { constructor(config) { this.keyPrefix = config.upstash?.keyPrefix || "ratelimit:"; this.config = config.upstash; } async init() { try { const { Redis } = await import("@upstash/redis"); this.client = new Redis({ url: this.config.url, token: this.config.token }); await this.client.ping(); } catch (error) { console.error("Failed to initialize Upstash client:", error); throw new Error("Upstash initialization failed"); } } async get(key) { try { const data = await this.client.get(this.keyPrefix + key); if (!data) return null; if (typeof data === "string") { try { return JSON.parse(data); } catch (parseError) { console.error("Error parsing JSON from Upstash:", parseError); return null; } } else if (typeof data === "object") { return data; } return null; } catch (error) { console.error("Upstash get error:", error); return null; } } async set(key, value) { try { const ttl = Math.ceil((value.firstRequest + 36e5 - Date.now()) / 1e3); const stringValue = JSON.stringify(value); await this.client.set(this.keyPrefix + key, stringValue, { ex: ttl > 0 ? ttl : 60 }); } catch (error) { console.error("Upstash set error:", error); } } async increment(key, value = 1) { try { const fullKey = this.keyPrefix + key; const now = Date.now(); const [exists] = await this.client.pipeline().exists(fullKey).exec(); if (!exists) { const record = { count: value, firstRequest: now, lastRequest: now, history: [now] }; await this.client.set(fullKey, JSON.stringify(record), { ex: 3600 }); return record; } else { const script = ` local record = cjson.decode(redis.call('get', KEYS[1])) record.count = record.count + ARGV[1] record.lastRequest = ARGV[2] if record.history then table.insert(record.history, ARGV[2]) else record.history = {ARGV[2]} end redis.call('set', KEYS[1], cjson.encode(record), 'EX', 3600) return cjson.encode(record) `; const result = await this.client.eval( script, [fullKey], [value.toString(), now.toString()] ); if (typeof result === "string") { try { return JSON.parse(result); } catch (parseError) { console.error("Error parsing script result:", parseError); throw new Error("Failed to parse increment result"); } } else if (typeof result === "object") { return result; } throw new Error("Invalid result type from Upstash script"); } } catch (error) { console.error("Upstash increment error:", error); const record = await this.get(key) || { count: 0, firstRequest: Date.now(), lastRequest: Date.now(), history: [] }; record.count += value; record.lastRequest = Date.now(); if (record.history) { record.history.push(record.lastRequest); } else { record.history = [record.lastRequest]; } await this.set(key, record); return record; } } async reset(key) { try { await this.client.del(this.keyPrefix + key); await this.client.del(this.keyPrefix + key + ":history"); } catch (error) { console.error("Upstash reset error:", error); } } async close() { return Promise.resolve(); } async addToHistory(key, timestamp) { try { const historyKey = this.keyPrefix + key + ":history"; await this.client.lpush(historyKey, timestamp.toString()); await this.client.expire(historyKey, 3600); } catch (error) { console.error("Upstash addToHistory error:", error); } } async getWindowHistory(key, startTime) { try { const historyKey = this.keyPrefix + key + ":history"; const allItems = await this.client.lrange(historyKey, 0, -1); if (!Array.isArray(allItems)) { return []; } return allItems.map((item) => typeof item === "string" ? parseInt(item, 10) : item).filter((time) => !isNaN(time) && time >= startTime); } catch (error) { console.error("Upstash getWindowHistory error:", error); return []; } } }; // src/storage/index.ts async function createStore(config) { const storageType = config.storage || "memory"; let store; switch (storageType) { case "redis": if (!config.redis) { throw new Error("Redis configuration is required when using Redis storage"); } store = new RedisStore(config); break; case "upstash": if (!config.upstash) { throw new Error("Upstash configuration is required when using Upstash storage"); } store = new UpstashStore(config); break; case "memory": default: store = new MemoryStore(config.memoryOptions || { max: 1e4, ttl: 36e5 }); break; } await store.init(); return store; } // src/strategies/fixed.ts var FixedWindowStrategy = class { /** * Checks the rate limit for a given key and returns the result. * * @param {string} key - The unique key representing the request (often an IP or identifier). * @param {SecurityContext} context - The security context containing the request data. * @param {RateLimitConfig} config - The configuration object containing limit and window settings. * @param {RateLimitStore} store - The store instance to get, set, or increment the rate limit record. * @returns {Promise<RateLimitResult>} A promise that resolves with the rate limit result including status, remaining quota, limit, and retry time. */ async check(key, context, config, store) { const now = Date.now(); const windowMs = config.windowMs; const limit = config.limit; let record = await store.get(key); if (!record || now - record.firstRequest > windowMs) { record = { count: 1, firstRequest: now, lastRequest: now }; await store.set(key, record); return { passed: true, remaining: limit - 1, limit, retry: windowMs }; } else { record = await store.increment(key); const timeElapsed = now - record.firstRequest; const timeRemaining = Math.max(0, windowMs - timeElapsed); if (record.count > limit) { return { passed: false, remaining: 0, limit, retry: timeRemaining, reason: "RATE_LIMITED", data: { windowMs, limit, current: record.count } }; } return { passed: true, remaining: Math.max(0, limit - record.count), limit, retry: timeRemaining }; } } }; // src/strategies/leaky-bucket.ts var LeakyBucketStrategy = class { async check(key, context, config, store) { const now = Date.now(); const outflowRate = config.limit / config.windowMs; const bucketSize = config.limit; let record = await store.get(key); if (!record) { record = { count: 1, firstRequest: now, lastRequest: now }; await store.set(key, record); return { passed: true, remaining: bucketSize - 1, limit: bucketSize, retry: 0 }; } else { const timeElapsed = now - record.lastRequest; const leakedRequests = timeElapsed * outflowRate; const newLevel = Math.max(0, record.count - leakedRequests); if (newLevel < bucketSize) { record.count = newLevel + 1; record.lastRequest = now; await store.set(key, record); return { passed: true, remaining: Math.floor(bucketSize - record.count), limit: bucketSize, retry: 0 }; } else { const waitTime = (newLevel - bucketSize + 1) / outflowRate; record.lastRequest = now; await store.set(key, record); return { passed: false, remaining: 0, limit: bucketSize, retry: Math.ceil(waitTime), reason: "RATE_LIMITED", data: { bucketSize, current: Math.ceil(newLevel) } }; } } } }; // src/strategies/sliding-window.ts var SlidingWindowStrategy = class { async check(key, context, config, store) { console.log(`[SlidingWindow] Checking key: ${key}`); const now = Date.now(); const windowMs = config.windowMs; const limit = config.limit; const windowStartTime = now - windowMs; let record = await store.get(key); if (!record) { record = { count: 1, // First request in this window firstRequest: now, lastRequest: now, history: [now] // Start history with current request timestamp }; console.log(`[SlidingWindow] New record created with count=${record.count}`); await store.set(key, record); return { passed: true, remaining: limit - 1, limit, retry: windowMs }; } if (!record.history) { record.history = []; } record.history.push(now); record.lastRequest = now; const windowHistory = record.history.filter((time) => time >= windowStartTime); const count = windowHistory.length; record.count = count; record.history = windowHistory; await store.set(key, record); if (count > limit) { const oldestRequest = Math.min(...windowHistory); const resetTime = oldestRequest + windowMs - now; console.log(`[SlidingWindow] Rate limit exceeded. Count=${count}, Limit=${limit}`); return { passed: false, remaining: 0, limit, retry: resetTime > 0 ? resetTime : 1e3, // Minimum retry time of 1 second reason: "RATE_LIMITED", data: { windowMs, limit, current: count } }; } return { passed: true, remaining: Math.max(0, limit - count), limit, retry: windowMs }; } }; // src/strategies/token-bucket.ts var TokenBucketStrategy = class { async check(key, context, config, store) { const now = Date.now(); const refillRate = config.limit / config.windowMs; const maxTokens = config.limit; let record = await store.get(key); if (!record) { record = { count: 0, firstRequest: now, lastRequest: now, tokens: maxTokens - 1 }; await store.set(key, record); return { passed: true, remaining: maxTokens - 1, limit: maxTokens, retry: 0 }; } else { const timeElapsed = now - record.lastRequest; const tokensToAdd = timeElapsed * refillRate; record.tokens = Math.min(maxTokens, (record.tokens || 0) + tokensToAdd); if (record.tokens >= 1) { record.tokens -= 1; record.lastRequest = now; record.count += 1; await store.set(key, record); return { passed: true, remaining: Math.floor(record.tokens), limit: maxTokens, retry: 0 }; } else { const timeForOneToken = (1 - record.tokens) / refillRate; record.lastRequest = now; await store.set(key, record); return { passed: false, remaining: 0, limit: maxTokens, retry: Math.ceil(timeForOneToken), reason: "RATE_LIMITED", data: { maxTokens, current: 0 } }; } } } }; // src/strategies/adaptive.ts var AdaptiveStrategy = class { async check(key, context, config, store) { const now = Date.now(); const baseLimit = config.limit; const windowMs = config.windowMs; if (!config.adaptive || !config.adaptive.enabled) { return new FixedWindowStrategy().check(key, context, config, store); } let record = await store.get(key); if (!record) { record = { count: 1, firstRequest: now, lastRequest: now, history: [now] }; await store.set(key, record); return { passed: true, remaining: baseLimit - 1, limit: baseLimit, retry: 0 }; } await store.addToHistory?.(key, now); const windowStartTime = now - windowMs; const recentHistory = await store.getWindowHistory?.(key, windowStartTime) || []; const requestCount = recentHistory.length; const requestRatePerSecond = requestCount / windowMs * 1e3; let burstScore = 0; if (recentHistory.length > 1) { const intervals = []; const sortedHistory = [...recentHistory].sort((a, b) => a - b); for (let i = 1; i < sortedHistory.length; i++) { intervals.push(sortedHistory[i] - sortedHistory[i - 1]); } const avgInterval = intervals.reduce((sum, val) => sum + val, 0) / intervals.length; const variance = intervals.reduce((sum, val) => sum + Math.pow(val - avgInterval, 2), 0) / intervals.length; const stdDev = Math.sqrt(variance); burstScore = avgInterval > 0 ? stdDev / avgInterval : 0; } let dynamicLimit = baseLimit; let escalationLevel = "normal"; if (requestRatePerSecond > config.adaptive.thresholds.extreme) { dynamicLimit = Math.floor(baseLimit / 10); escalationLevel = "extreme"; } else if (requestRatePerSecond > config.adaptive.thresholds.high) { dynamicLimit = Math.floor(baseLimit / 4); escalationLevel = "high"; } else if (requestRatePerSecond > config.adaptive.thresholds.elevated) { dynamicLimit = Math.floor(baseLimit / 2); escalationLevel = "elevated"; } record.count = requestCount; record.lastRequest = now; await store.set(key, record); if (requestCount > dynamicLimit) { const isEscalation = escalationLevel !== "normal" && (!record.ddosScore || record.ddosScore < 1); if (isEscalation) { record.ddosScore = 1; await store.set(key, record); } return { passed: false, remaining: 0, limit: dynamicLimit, retry: Math.ceil(windowMs / dynamicLimit), reason: isEscalation ? "RATE_LIMIT_ADAPTIVE_ESCALATION" : "RATE_LIMITED", data: { dynamicLimit, baseLimit, current: requestCount, escalationLevel, requestRate: requestRatePerSecond.toFixed(2), burstScore: burstScore.toFixed(2) } }; } return { passed: true, remaining: Math.max(0, dynamicLimit - requestCount), limit: dynamicLimit, retry: 0 }; } }; // src/strategies/factory.ts function createStrategy(config) { const strategyType = config.strategy || "fixed-window"; switch (strategyType) { case "sliding-window": return new SlidingWindowStrategy(); case "token-bucket": return new TokenBucketStrategy(); case "leaky-bucket": return new LeakyBucketStrategy(); case "adaptive": return new AdaptiveStrategy(); case "fixed-window": default: return new FixedWindowStrategy(); } } var DDoSProtection = class { /** * Creates an instance of DDoSProtection. */ constructor(config) { this.config = config; this.blacklist = /* @__PURE__ */ new Map(); // Pre-allocate threat level thresholds this.CRITICAL_THRESHOLD = 0.8; this.HIGH_THRESHOLD = 0.6; this.MEDIUM_THRESHOLD = 0.4; this.LOW_THRESHOLD = 0.2; this.enabled = !!config.ddosPrevention?.enabled; this.requestRateThreshold = config.ddosPrevention?.requestRateThreshold || 20; this.burstThreshold = config.ddosPrevention?.burstThreshold || 10; this.banDurationMs = config.ddosPrevention?.banDurationMs || 6e5; this.windowMs = config.windowMs || 6e4; } /** * Fast check to determine if IP is a known threat * This is a quick first-pass filter before more expensive analysis */ isKnownThreat(ip) { if (!this.enabled) { return { isThreat: false }; } if (this.isBlacklisted(ip)) { return { isThreat: true, level: "critical", banDuration: this.banDurationMs }; } return { isThreat: false }; } /** * Optimized analysis algorithm for DDoS threat detection * Employs early exits and efficient calculations */ async analyze(key, context, store) { if (!this.enabled) { return { isThreat: false, level: "none", score: 0 }; } const ip = key.includes(":") ? key.substring(0, key.indexOf(":")) : key; const knownThreat = this.isKnownThreat(ip); if (knownThreat.isThreat) { return { isThreat: true, level: "critical", // Type cast to match return type score: 1, banDuration: this.banDurationMs }; } const now = Date.now(); const windowStartTime = now - this.windowMs; const [record, recentHistory] = await Promise.all([ store.get(key), store.getWindowHistory?.(key, windowStartTime) || Promise.resolve([]) ]); const historyLength = recentHistory.length; if (!record || historyLength < 5) { return { isThreat: false, level: "none", score: 0 }; } let threatScore = 0; const requestRate = historyLength / (this.windowMs / 1e3); if (requestRate > this.requestRateThreshold) { const rateScore = Math.min(0.4, requestRate / this.requestRateThreshold * 0.4); threatScore += rateScore; if (requestRate < this.requestRateThreshold * 0.5) { return { isThreat: false, level: "none", score: parseFloat(threatScore.toFixed(2)) }; } } const lastSecondStartTime = now - 1e3; let lastSecondRequests = 0; for (let i = 0; i < historyLength; i++) { if (recentHistory[i] >= lastSecondStartTime) { lastSecondRequests++; } } if (lastSecondRequests > this.burstThreshold) { const burstScore = Math.min(0.3, lastSecondRequests / this.burstThreshold * 0.3); threatScore += burstScore; } if (threatScore > 0.15) { let intervals; let isOrdered = true; for (let i = 1; i < historyLength; i++) { if (recentHistory[i] < recentHistory[i - 1]) { isOrdered = false; break; } } if (isOrdered) { intervals = new Array(historyLength - 1); for (let i = 1; i < historyLength; i++) { intervals[i - 1] = recentHistory[i] - recentHistory[i - 1]; } } else { const tempArray = recentHistory.slice().sort((a, b) => a - b); intervals = new Array(historyLength - 1); for (let i = 1; i < historyLength; i++) { intervals[i - 1] = tempArray[i] - tempArray[i - 1]; } } if (intervals.length > 10) { let sum = 0; for (let i = 0; i < intervals.length; i++) { sum += intervals[i]; } const avgInterval = sum / intervals.length; if (avgInterval > 0) { let varianceSum = 0; for (let i = 0; i < intervals.length; i++) { const diff = intervals[i] - avgInterval; varianceSum += diff * diff; } const variance = varianceSum / intervals.length; const stdDev = Math.sqrt(variance); const intervalConsistency = 1 - stdDev / avgInterval; if (intervalConsistency > 0.7) { threatScore += intervalConsistency * 0.2; } } } } const headers = context.request.headers; if (!(headers["user-agent"] && (headers["accept-language"] || headers["accept"]))) { threatScore += 0.1; } let threatLevel; if (threatScore > this.CRITICAL_THRESHOLD) { threatLevel = "critical"; this.blacklist.set(ip, now + this.banDurationMs); } else if (threatScore > this.HIGH_THRESHOLD) { threatLevel = "high"; } else if (threatScore > this.MEDIUM_THRESHOLD) { threatLevel = "medium"; } else if (threatScore > this.LOW_THRESHOLD) { threatLevel = "low"; } else { threatLevel = "none"; } return { isThreat: threatLevel !== "none" && threatLevel !== "low", level: threatLevel, score: parseFloat(threatScore.toFixed(2)), banDuration: threatLevel === "critical" ? this.banDurationMs : void 0 }; } /** * Optimized blacklist check with efficient expiration handling */ isBlacklisted(ip) { const now = Date.now(); const expirationTime = this.blacklist.get(ip); if (!expirationTime) { return false; } if (expirationTime > now) { return true; } this.blacklist.delete(ip); return false; } /** * Get approximate blacklist size */ getBlacklistSize() { return this.blacklist.size; } /** * Clear expired blacklist entries * Call periodically for memory management */ cleanBlacklist() { const now = Date.now(); let removed = 0; for (const [ip, expiration] of this.blacklist.entries()) { if (expiration <= now) { this.blacklist.delete(ip); removed++; } } return removed; } }; // src/utils/extract-ip.ts function extractIp(request, ipHeaders = [], useRemoteAddress = true) { if (request.headers) { for (const header of ipHeaders) { const value = request.headers[header.toLowerCase()]; if (value) { const parts = value.split(","); return parts[0].trim(); } } } if (useRemoteAddress) { if (request.connection && request.connection.remoteAddress) { return request.connection.remoteAddress; } if (request.socket && request.socket.remoteAddress) { return request.socket.remoteAddress; } } return null; } // src/geo/ipapi.ts var countryCache = /* @__PURE__ */ new Map(); var CACHE_TTL = 24 * 60 * 60 * 1e3; var IpApiProvider = class { constructor(options = {}) { this.options = options; if (options.cacheTtl) { CACHE_TTL = options.cacheTtl; } } async lookupCountry(ip) { try { const now = Date.now(); const cached = countryCache.get(ip); if (cached && now - cached.timestamp < CACHE_TTL) { return cached.country; } console.log(`[GeoIP] Looking up country for IP: ${ip}`); const response = await fetch( `http://ip-api.com/json/${ip}?fields=status,message,countryCode` ); const data = await response.json(); if (data && data.status === "success" && data.countryCode) { countryCache.set(ip, { country: data.countryCode, timestamp: now }); return data.countryCode; } return null; } catch (error) { console.error(`[GeoIP] Error looking up country for IP ${ip}:`, error); return null; } } }; // src/geo/index.ts var globalGeoProvider = null; async function createGeoProvider(config) { if (globalGeoProvider) { return globalGeoProvider; } if (!config.geoProvider) { return null; } const providerType = config.geoProvider.type; switch (providerType) { case "maxmind": if (!config.geoProvider.dbPath) { console.error("MaxMind DB path is required"); return null; } case "ipapi": default: globalGeoProvider = new IpApiProvider({ cacheTtl: config.geoProvider.cacheTtl }); break; } return globalGeoProvider; } // src/index.ts function addRateLimitHeaders(context, result, config) { if (!config.headers) return; const res = context.response; const remaining = result.remaining !== void 0 ? result.remaining : 0; const limit = result.limit !== void 0 ? result.limit : config.limit; const resetTime = result.retry ? new Date(Date.now() + result.retry).getTime() : void 0; if (config.standardHeaders) { res.setHeader(config.headerLimit || "X-RateLimit-Limit", limit.toString()); res.setHeader( config.headerRemaining || "X-RateLimit-Remaining", Math.max(0, remaining).toString() ); if (resetTime) { res.setHeader( config.headerReset || "X-RateLimit-Reset", Math.ceil(resetTime / 1e3).toString() ); } } res.setHeader("RateLimit-Limit", limit.toString()); res.setHeader("RateLimit-Remaining", Math.max(0, remaining).toString()); if (resetTime) { res.setHeader("RateLimit-Reset", Math.ceil(resetTime / 1e3).toString()); } } var globalStore = null; var DEFAULT_CONFIG = { limit: 100, windowMs: 6e4, strategy: "fixed-window", storage: "memory", ipHeaders: ["cf-connecting-ip", "x-forwarded-for", "x-real-ip"], useRemoteAddress: true, headers: true, standardHeaders: true, headerLimit: "X-RateLimit-Limit", headerRemaining: "X-RateLimit-Remaining", headerReset: "X-RateLimit-Reset", statusCode: 429, message: "Too many requests, please try again later.", memoryOptions: { max: 1e4, ttl: 36e5 }, ddosPrevention: { enabled: false, requestRateThreshold: 50, burstThreshold: 20, banDurationMs: 36e5 } }; var rateLimit = (0, import_core.createModule)({ name: "rate-limit", defaultConfig: DEFAULT_CONFIG, async check(context, config) { try { if (!globalStore) { console.log("Creating persistent store"); globalStore = await createStore(config); } const store = globalStore; if (config.skipFunction && await config.skipFunction(context)) { return { passed: true }; } let identifier; if (config.keyGenerator) { identifier = await config.keyGenerator(context); } else { identifier = extractIp(context.request, config.ipHeaders, config.useRemoteAddress) || "unknown"; } const path = context.request.url || ""; let resource = void 0; if (config.resources) { for (const [resourceKey, resourceConfig] of Object.entries(config.resources)) { if (path.includes(resourceKey)) { resource = resourceKey; break; } } } let country = void 0; country = context.data.get("geo-block:country"); if (!country && config.countryLimits && config.geoProvider) { const geoProvider = await createGeoProvider(config); if (geoProvider) { country = await geoProvider.lookupCountry(identifier); if (country) { context.data.set("rate-limit:country", country); } } } const key = `${identifier}${resource ? `:${resource}` : ""}${country ? `:${country}` : ""}`; if (config.ddosPrevention?.enabled) { const ddosProtection = new DDoSProtection(config); const threatAnalysis = await ddosProtection.analyze(key, context, store); if (threatAnalysis.isThreat) { context.data.set("rate-limit:ddos", threatAnalysis); return { passed: false, reason: "ddos_protection_triggered" /* DDOS_PROTECTION_TRIGGERED */, data: threatAnalysis, severity: threatAnalysis.level === "critical" ? "high" : "medium" }; } } let effectiveConfig = { ...config }; if (resource && config.resources && config.resources[resource]) { effectiveConfig.limit = config.resources[resource].limit; effectiveConfig.windowMs = config.resources[resource].windowMs; } if (country && config.countryLimits && config.countryLimits[country]) { effectiveConfig.limit = config.countryLimits[country].limit; effectiveConfig.windowMs = config.countryLimits[country].windowMs; } const strategy = createStrategy(effectiveConfig); const result = await strategy.check(key, context, effectiveConfig, store); context.data.set("rate-limit:result", result); if (config.headers && result) { addRateLimitHeaders(context, result, config); } if (!result.passed) { return { passed: false, reason: result.reason || "rate_limited" /* RATE_LIMITED */, data: result.data, severity: "low" }; } return { passed: true }; } catch (error) { console.error("Error in rate-limit module:", error); return { passed: true }; } }, async handleFailure(context, reason, data) { const config = context.data.get("rate-limit:config"); const result = context.data.get("rate-limit:result"); const res = context.response; if (res.headersSent || res.writableEnded) { return; } if (config.headers && result) { addRateLimitHeaders(context, result, config); } if (config.handler) { return config.handler(context, { reason, data }); } const statusCode = config.statusCode || 429; let message = config.message || "Too many requests, please try again later."; if (reason === "ddos_protection_triggered" /* DDOS_PROTECTION_TRIGGERED */) { message = "Access temporarily blocked due to suspicious traffic patterns."; } if (result && result.retry) { res.setHeader("Retry-After", Math.ceil(result.retry / 1e3).toString()); } if (typeof res.status === "function") { return res.status(statusCode).json({ error: message, retryAfter: result && result.retry ? Math.ceil(result.retry / 1e3) : void 0 }); } else if (typeof res.statusCode === "number") { res.statusCode = statusCode; res.setHeader("Content-Type", "application/json"); return res.end( JSON.stringify({ error: message, retryAfter: result && result.retry ? Math.ceil(result.retry / 1e3) : void 0 }) ); } } }); (0, import_core.registerModule)("rateLimit", rateLimit); // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { AdaptiveStrategy, DDoSProtection, FixedWindowStrategy, LeakyBucketStrategy, MemoryStore, RateLimitEventType, RedisStore, SlidingWindowStrategy, TokenBucketStrategy, UpstashStore, createStore, createStrategy, rateLimit });