]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ui-server): prevent rate limiter memory leak
authorJérôme Benoit <jerome.benoit@sap.com>
Thu, 12 Feb 2026 18:04:30 +0000 (19:04 +0100)
committerJérôme Benoit <jerome.benoit@sap.com>
Thu, 12 Feb 2026 18:04:30 +0000 (19:04 +0100)
Add maxTrackedIps limit with lazy cleanup to bound memory usage.
Reject new IPs at capacity after cleanup (DoS protection).

src/charging-station/ui-server/AbstractUIServer.ts
src/charging-station/ui-server/UIServerSecurity.ts
tests/charging-station/ui-server/UIServerSecurity.test.ts

index f9d2aa0dff0378864d1eadf0c0001134f17d1e83..20bf50fce579e919e22d5e15a064c77e350b93c4 100644 (file)
@@ -22,8 +22,8 @@ import {
 } from '../../types/index.js'
 import { isEmpty, logger } from '../../utils/index.js'
 import { UIServiceFactory } from './ui-services/UIServiceFactory.js'
-import { getUsernameAndPasswordFromAuthorizationToken } from './UIServerUtils.js'
 import { isValidCredential } from './UIServerSecurity.js'
+import { getUsernameAndPasswordFromAuthorizationToken } from './UIServerUtils.js'
 
 const moduleName = 'AbstractUIServer'
 
index 231226fe6046b37e67fda6ad0829e63c680d5bb1..8b6beb176478a17121f605d702b5efcc4b8d4baf 100644 (file)
@@ -1,10 +1,19 @@
 import { timingSafeEqual } from 'node:crypto'
 
+/**
+ * Per-IP rate limiter state
+ */
+interface RateLimitEntry {
+  count: number
+  resetTime: number
+}
+
 export const DEFAULT_MAX_BODY_SIZE = 1048576
 export const DEFAULT_RATE_LIMIT = 100
 export const DEFAULT_RATE_WINDOW = 60000
 export const DEFAULT_MAX_STATIONS = 100
 export const DEFAULT_WS_MAX_PAYLOAD = 102400
+export const DEFAULT_MAX_TRACKED_IPS = 10000
 
 /**
  * Constant-time credential comparison using crypto.timingSafeEqual
@@ -46,29 +55,41 @@ export const createBodySizeLimiter = (maxBytes: number): ((chunkSize: number) =>
   }
 }
 
-/**
- * Per-IP rate limiter state
- */
-interface RateLimitEntry {
-  count: number
-  resetTime: number
-}
-
 /**
  * Creates a rate limiter function that tracks requests per IP address
- * Uses a simple fixed-window approach (not sliding window)
+ * Uses a simple fixed-window approach with lazy cleanup to prevent memory leaks
  * @param maxRequests - Maximum requests allowed per window
  * @param windowMs - Time window in milliseconds
+ * @param maxTrackedIps - Maximum number of IPs to track (prevents memory exhaustion)
  * @returns Function that checks if IP is within rate limit
  */
 export const createRateLimiter = (
   maxRequests: number,
-  windowMs: number
+  windowMs: number,
+  maxTrackedIps: number = DEFAULT_MAX_TRACKED_IPS
 ): ((ipAddress: string) => boolean) => {
   const trackedIps = new Map<string, RateLimitEntry>()
 
+  const cleanupExpiredEntries = (now: number): void => {
+    for (const [ip, entry] of trackedIps.entries()) {
+      if (now >= entry.resetTime) {
+        trackedIps.delete(ip)
+      }
+    }
+  }
+
   return (ipAddress: string): boolean => {
     const now = Date.now()
+
+    // Lazy cleanup: when at capacity and new IP arrives, clean expired entries
+    if (trackedIps.size >= maxTrackedIps && !trackedIps.has(ipAddress)) {
+      cleanupExpiredEntries(now)
+      // If still at capacity after cleanup, reject new IPs (DoS protection)
+      if (trackedIps.size >= maxTrackedIps) {
+        return false
+      }
+    }
+
     const entry = trackedIps.get(ipAddress)
 
     // First request from this IP or window expired
index 0ee4a6acc2d00f827204a5683521589394e3ee5a..7a4ad9c30f4a072791a75b7a935b28f4fd643e40 100644 (file)
@@ -79,14 +79,40 @@ await describe('UIServerSecurity test suite', async () => {
       limiter('10.0.0.1')
       expect(limiter('10.0.0.1')).toBe(false)
 
-      // Wait for window to expire
       await new Promise(resolve => {
         setTimeout(resolve, 110)
       })
 
-      // Should allow request after window reset
       expect(limiter('10.0.0.1')).toBe(true)
     })
+
+    await it('should reject new IPs when at max tracked IPs capacity', () => {
+      const limiter = createRateLimiter(10, 60000, 3)
+      expect(limiter('192.168.1.1')).toBe(true)
+      expect(limiter('192.168.1.2')).toBe(true)
+      expect(limiter('192.168.1.3')).toBe(true)
+      expect(limiter('192.168.1.4')).toBe(false)
+    })
+
+    await it('should still allow existing IPs when at capacity', () => {
+      const limiter = createRateLimiter(10, 60000, 2)
+      expect(limiter('192.168.1.1')).toBe(true)
+      expect(limiter('192.168.1.2')).toBe(true)
+      expect(limiter('192.168.1.1')).toBe(true)
+      expect(limiter('192.168.1.2')).toBe(true)
+    })
+
+    await it('should cleanup expired entries when at capacity', async () => {
+      const limiter = createRateLimiter(10, 50, 2)
+      expect(limiter('192.168.1.1')).toBe(true)
+      expect(limiter('192.168.1.2')).toBe(true)
+
+      await new Promise(resolve => {
+        setTimeout(resolve, 60)
+      })
+
+      expect(limiter('192.168.1.3')).toBe(true)
+    })
   })
 
   await describe('isValidNumberOfStations()', async () => {