@ordojs/security
Version:
Security package for OrdoJS with XSS, CSRF, and injection protection
319 lines (270 loc) • 8.79 kB
text/typescript
/**
* Rate Limiter
* Provides request throttling and rate limiting functionality
*/
import type { RateLimitOptions, RateLimitStore } from './types';
/**
* In-memory rate limit store
*/
export class MemoryRateLimitStore implements RateLimitStore {
private store = new Map<string, { count: number; resetTime: number }>();
private cleanupInterval: NodeJS.Timeout;
constructor() {
// Clean up expired entries every minute
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, 60000);
}
async get(key: string): Promise<number | null> {
const entry = this.store.get(key);
if (!entry || Date.now() > entry.resetTime) {
return null;
}
return entry.count;
}
async set(key: string, value: number, ttl: number): Promise<void> {
this.store.set(key, {
count: value,
resetTime: Date.now() + ttl
});
}
async increment(key: string, ttl: number): Promise<number> {
const entry = this.store.get(key);
const now = Date.now();
if (!entry || now > entry.resetTime) {
// Create new entry
this.store.set(key, {
count: 1,
resetTime: now + ttl
});
return 1;
} else {
// Increment existing entry
entry.count++;
return entry.count;
}
}
async reset(key: string): Promise<void> {
this.store.delete(key);
}
private cleanup(): void {
const now = Date.now();
for (const [key, entry] of this.store.entries()) {
if (now > entry.resetTime) {
this.store.delete(key);
}
}
}
destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
this.store.clear();
}
}
/**
* Redis-based rate limit store
*/
export class RedisRateLimitStore implements RateLimitStore {
constructor(private redisClient: any) {}
async get(key: string): Promise<number | null> {
const value = await this.redisClient.get(key);
return value ? parseInt(value, 10) : null;
}
async set(key: string, value: number, ttl: number): Promise<void> {
await this.redisClient.setex(key, Math.ceil(ttl / 1000), value);
}
async increment(key: string, ttl: number): Promise<number> {
const multi = this.redisClient.multi();
multi.incr(key);
multi.expire(key, Math.ceil(ttl / 1000));
const results = await multi.exec();
return results[0][1]; // Return the incremented value
}
async reset(key: string): Promise<void> {
await this.redisClient.del(key);
}
}
/**
* Rate limiter implementation
*/
export class RateLimiter {
private store: RateLimitStore;
private options: Required<RateLimitOptions>;
constructor(options: RateLimitOptions, store?: RateLimitStore) {
this.options = {
windowMs: options.windowMs,
maxRequests: options.maxRequests,
keyGenerator: options.keyGenerator || this.defaultKeyGenerator,
skipSuccessfulRequests: options.skipSuccessfulRequests || false,
skipFailedRequests: options.skipFailedRequests || false,
onLimitReached: options.onLimitReached || (() => {})
};
this.store = store || new MemoryRateLimitStore();
}
/**
* Check if request should be rate limited
*/
async checkLimit(request: any): Promise<{
allowed: boolean;
limit: number;
remaining: number;
resetTime: number;
retryAfter?: number;
}> {
const key = this.options.keyGenerator(request);
const now = Date.now();
const windowStart = now - this.options.windowMs;
// Get current count
const currentCount = await this.store.get(key) || 0;
// Check if limit exceeded
if (currentCount >= this.options.maxRequests) {
const resetTime = now + this.options.windowMs;
const retryAfter = Math.ceil(this.options.windowMs / 1000);
// Call limit reached callback
this.options.onLimitReached(request);
return {
allowed: false,
limit: this.options.maxRequests,
remaining: 0,
resetTime,
retryAfter
};
}
// Increment counter
const newCount = await this.store.increment(key, this.options.windowMs);
return {
allowed: true,
limit: this.options.maxRequests,
remaining: Math.max(0, this.options.maxRequests - newCount),
resetTime: now + this.options.windowMs
};
}
/**
* Reset rate limit for a specific key
*/
async resetLimit(request: any): Promise<void> {
const key = this.options.keyGenerator(request);
await this.store.reset(key);
}
/**
* Default key generator using IP address
*/
private defaultKeyGenerator(request: any): string {
// Try to get IP from various sources
const ip = request.ip ||
request.connection?.remoteAddress ||
request.socket?.remoteAddress ||
request.headers?.['x-forwarded-for']?.split(',')[0]?.trim() ||
request.headers?.['x-real-ip'] ||
'unknown';
return `rate_limit:${ip}`;
}
/**
* Create middleware for Express-like frameworks
*/
middleware() {
return async (req: any, res: any, next: any) => {
try {
const result = await this.checkLimit(req);
// Set rate limit headers
res.setHeader('X-RateLimit-Limit', result.limit);
res.setHeader('X-RateLimit-Remaining', result.remaining);
res.setHeader('X-RateLimit-Reset', new Date(result.resetTime).toISOString());
if (!result.allowed) {
res.setHeader('Retry-After', result.retryAfter);
res.status(429).json({
error: 'Too Many Requests',
message: 'Rate limit exceeded',
retryAfter: result.retryAfter
});
return;
}
next();
} catch (error) {
console.error('Rate limiter error:', error);
next(); // Continue on error to avoid breaking the application
}
};
}
}
/**
* Sliding window rate limiter for more precise control
*/
export class SlidingWindowRateLimiter {
private store: RateLimitStore;
private options: Required<RateLimitOptions>;
constructor(options: RateLimitOptions, store?: RateLimitStore) {
this.options = {
windowMs: options.windowMs,
maxRequests: options.maxRequests,
keyGenerator: options.keyGenerator || this.defaultKeyGenerator,
skipSuccessfulRequests: options.skipSuccessfulRequests || false,
skipFailedRequests: options.skipFailedRequests || false,
onLimitReached: options.onLimitReached || (() => {})
};
this.store = store || new MemoryRateLimitStore();
}
async checkLimit(request: any): Promise<{
allowed: boolean;
limit: number;
remaining: number;
resetTime: number;
retryAfter?: number;
}> {
const key = this.options.keyGenerator(request);
const now = Date.now();
const windowStart = now - this.options.windowMs;
// For sliding window, we need to track individual request timestamps
// This is a simplified implementation - in production, you'd want to use
// a more sophisticated data structure like a sorted set in Redis
const currentCount = await this.store.get(key) || 0;
if (currentCount >= this.options.maxRequests) {
this.options.onLimitReached(request);
return {
allowed: false,
limit: this.options.maxRequests,
remaining: 0,
resetTime: now + this.options.windowMs,
retryAfter: Math.ceil(this.options.windowMs / 1000)
};
}
const newCount = await this.store.increment(key, this.options.windowMs);
return {
allowed: true,
limit: this.options.maxRequests,
remaining: Math.max(0, this.options.maxRequests - newCount),
resetTime: now + this.options.windowMs
};
}
private defaultKeyGenerator(request: any): string {
const ip = request.ip ||
request.connection?.remoteAddress ||
request.socket?.remoteAddress ||
'unknown';
return `sliding_rate_limit:${ip}`;
}
middleware() {
return async (req: any, res: any, next: any) => {
try {
const result = await this.checkLimit(req);
res.setHeader('X-RateLimit-Limit', result.limit);
res.setHeader('X-RateLimit-Remaining', result.remaining);
res.setHeader('X-RateLimit-Reset', new Date(result.resetTime).toISOString());
if (!result.allowed) {
res.setHeader('Retry-After', result.retryAfter);
res.status(429).json({
error: 'Too Many Requests',
message: 'Rate limit exceeded',
retryAfter: result.retryAfter
});
return;
}
next();
} catch (error) {
console.error('Sliding window rate limiter error:', error);
next();
}
};
}
}