OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-wallet-lib / rate-limiter.cjs

266 lines · javascript ref:
/**
 * 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
}