@lock-dev/rate-limit
Version:
Rate limiter module for lock.dev security framework
1,291 lines (1,274 loc) • 41.6 kB
JavaScript
"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
});