/**
* FILE: lib/rate-limiter.cjs
* STATUS: ACTIVE
* RUNTIME: Bare
* MODULE: CommonJS
*
* PURPOSE:
* Rate limiting middleware to prevent abuse and spam
*
* USED BY:
* - wallet-server.cjs (rate limiting middleware)
*
* KEY EXPORTS:
* - createRateLimiter() - Create rate limiter instance
* - checkRateLimit(req, endpoint) - Check if request is within limits
*
* RATE LIMITS:
* - /api/send: 10 tx per minute
* - /api/export-identity: 3 per hour
* - /api/export-wallet: 3 per hour
* - /api/device/add: 5 per 5 minutes
* - /api/device/join: 5 per 5 minutes
* - default: 100 per minute
*/
// Rate limit configurations (per IP address)
const RATE_LIMITS = {
'/api/send': { window: 60000, max: 10 }, // 10 transactions per minute
'/api/export-identity': { window: 3600000, max: 3 }, // 3 per hour
'/api/export-wallet': { window: 3600000, max: 3 }, // 3 per hour
'/api/device/add': { window: 300000, max: 5 }, // 5 per 5 minutes
'/api/device/join': { window: 300000, max: 5 }, // 5 per 5 minutes
'/api/estimate-fee': { window: 60000, max: 50 }, // 50 per minute
'/api/transactions/sync': { window: 300000, max: 10 }, // 10 per 5 minutes
'default': { window: 60000, max: 100 } // 100 per minute (general)
}
/**
* Rate limiter class
* Tracks request counts per IP and endpoint
*/
class RateLimiter {
constructor() {
// Map: IP → Map: endpoint → [timestamps]
this.requests = new Map()
}
/**
* Check if request is within rate limits
* @param {string} ip - Client IP address
* @param {string} endpoint - API endpoint path
* @returns {Object} { allowed: boolean, remaining: number, resetIn: number, headers: Object }
*/
checkLimit(ip, endpoint) {
// Get rate limit for this endpoint (or default)
const limit = RATE_LIMITS[endpoint] || RATE_LIMITS.default
const now = Date.now()
// Initialize IP tracking if needed
if (!this.requests.has(ip)) {
this.requests.set(ip, new Map())
}
const ipRequests = this.requests.get(ip)
// Initialize endpoint tracking if needed
if (!ipRequests.has(endpoint)) {
ipRequests.set(endpoint, [])
}
const timestamps = ipRequests.get(endpoint)
// Remove timestamps outside the time window
const recentRequests = timestamps.filter(ts => now - ts < limit.window)
// Check if limit exceeded
if (recentRequests.length >= limit.max) {
const oldestRequest = Math.min(...recentRequests)
const resetIn = limit.window - (now - oldestRequest)
const resetTime = Math.floor((now + resetIn) / 1000)
return {
allowed: false,
remaining: 0,
resetIn: Math.ceil(resetIn / 1000), // seconds
limit: limit.max,
window: Math.floor(limit.window / 1000), // seconds
headers: {
'X-RateLimit-Limit': String(limit.max),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': String(resetTime)
}
}
}
// Add current request timestamp
recentRequests.push(now)
ipRequests.set(endpoint, recentRequests)
const resetTime = Math.floor((now + limit.window) / 1000)
return {
allowed: true,
remaining: limit.max - recentRequests.length,
resetIn: Math.floor(limit.window / 1000), // seconds
limit: limit.max,
window: Math.floor(limit.window / 1000), // seconds
headers: {
'X-RateLimit-Limit': String(limit.max),
'X-RateLimit-Remaining': String(limit.max - recentRequests.length),
'X-RateLimit-Reset': String(resetTime)
}
}
}
/**
* Get current status for an IP/endpoint combination
* @param {string} ip - Client IP address
* @param {string} endpoint - API endpoint path
* @returns {Object} Current rate limit status
*/
getStatus(ip, endpoint) {
const limit = RATE_LIMITS[endpoint] || RATE_LIMITS.default
const now = Date.now()
if (!this.requests.has(ip)) {
return {
remaining: limit.max,
limit: limit.max,
window: Math.floor(limit.window / 1000)
}
}
const ipRequests = this.requests.get(ip)
if (!ipRequests.has(endpoint)) {
return {
remaining: limit.max,
limit: limit.max,
window: Math.floor(limit.window / 1000)
}
}
const timestamps = ipRequests.get(endpoint)
const recentRequests = timestamps.filter(ts => now - ts < limit.window)
return {
remaining: Math.max(0, limit.max - recentRequests.length),
limit: limit.max,
window: Math.floor(limit.window / 1000)
}
}
/**
* Clean up old entries (garbage collection)
* Call periodically to prevent memory buildup
*/
cleanup() {
const now = Date.now()
let cleaned = 0
for (const [ip, endpoints] of this.requests.entries()) {
for (const [endpoint, timestamps] of endpoints.entries()) {
const limit = RATE_LIMITS[endpoint] || RATE_LIMITS.default
const recentRequests = timestamps.filter(ts => now - ts < limit.window)
if (recentRequests.length === 0) {
endpoints.delete(endpoint)
cleaned++
} else {
endpoints.set(endpoint, recentRequests)
}
}
if (endpoints.size === 0) {
this.requests.delete(ip)
}
}
if (cleaned > 0) {
console.log(`🧹 Rate limiter cleanup: removed ${cleaned} stale entries`)
}
}
}
// Create global rate limiter instance
const rateLimiter = new RateLimiter()
// Cleanup every 5 minutes
setInterval(() => rateLimiter.cleanup(), 5 * 60 * 1000)
/**
* Extract client IP from request
* @param {Request} req - HTTP request
* @returns {string} Client IP address
*/
function getClientIP(req) {
// Check X-Forwarded-For header (if behind proxy)
const forwardedFor = req.headers['x-forwarded-for']
if (forwardedFor) {
// Take first IP if multiple
return forwardedFor.split(',')[0].trim()
}
// Use socket remote address
return req.socket.remoteAddress || '0.0.0.0'
}
/**
* Normalize endpoint path for rate limiting
* Removes query params and route params
* @param {string} url - Request URL
* @returns {string} Normalized endpoint
*/
function normalizeEndpoint(url) {
// Remove query string
const path = url.split('?')[0]
// Normalize parameterized routes
// /api/balance/testnet-bitcoin → /api/balance/:chain
// /api/address/testnet-bitcoin → /api/address/:chain
// /api/transactions/bitcoin → /api/transactions/:chain
// /api/device/add/abc123 → /api/device/add/:sessionId
if (path.match(/^\/api\/balance\//)) return '/api/balance/:chain'
if (path.match(/^\/api\/address\//)) return '/api/address/:chain'
if (path.match(/^\/api\/qrcode\//)) return '/api/qrcode/:chain'
if (path.match(/^\/api\/transactions\/[^/]+$/)) return '/api/transactions/:chain'
if (path.match(/^\/api\/transaction\//)) return '/api/transaction/:chain/:hash'
if (path.match(/^\/api\/contacts\//)) return '/api/contacts/:id'
if (path.match(/^\/api\/device\/add\/[^/]+$/)) return '/api/device/add/:sessionId'
if (path.match(/^\/api\/settings\/providers\//)) return '/api/settings/providers/:chain'
return path
}
/**
* Check rate limit for request
* @param {Request} req - HTTP request
* @returns {Object} Rate limit result
*/
function checkRateLimit(req) {
const ip = getClientIP(req)
const endpoint = normalizeEndpoint(req.url)
return rateLimiter.checkLimit(ip, endpoint)
}
/**
* Get rate limit status for request
* @param {Request} req - HTTP request
* @returns {Object} Current status
*/
function getRateLimitStatus(req) {
const ip = getClientIP(req)
const endpoint = normalizeEndpoint(req.url)
return rateLimiter.getStatus(ip, endpoint)
}
module.exports = {
checkRateLimit,
getRateLimitStatus,
RateLimiter,
RATE_LIMITS
}