From 5b00534e64547a50120372ad7f98fbc5b0fe629f Mon Sep 17 00:00:00 2001
From: Sanan507 <227714367+Sanan507@users.noreply.github.com>
Date: Fri, 11 Sep 2026 15:32:29 +0000
Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=20Fix=20DoS=20Risk=20in=20Rate=20L?=
=?UTF-8?q?imiter=20via=20Unbounded=20Memory?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Migrate `RateLimitFilter` from an unbounded `ConcurrentHashMap` to a size-bounded, time-evicting `Caffeine` cache to prevent memory exhaustion DoS attacks.
---
.jules/sentinel.md | 8 ++++----
backend/pom.xml | 4 ++++
.../visualizer/RateLimitFilter.java | 16 ++++++++++------
3 files changed, 18 insertions(+), 10 deletions(-)
diff --git a/.jules/sentinel.md b/.jules/sentinel.md
index 93201f0..0a6a29d 100644
--- a/.jules/sentinel.md
+++ b/.jules/sentinel.md
@@ -1,4 +1,4 @@
-## 2026-08-01 - Proper Handling of X-Forwarded-For in Rate Limiters
-**Vulnerability:** IP Spoofing via `X-Forwarded-For` header. The rate limiter incorrectly trusted the first IP in the `X-Forwarded-For` chain without verifying if the direct connection was from a trusted internal proxy. It also failed to parse the chain right-to-left, making it trivial for an external client to spoof an IP by prepending it to the header.
-**Learning:** Even when behind a reverse proxy, you cannot blindly trust `X-Forwarded-For`. An attacker can spoof it. You must verify that `request.getRemoteAddr()` (the direct connection) belongs to a trusted proxy. Furthermore, because legitimate proxies append to the end of the chain, you must parse the chain from right-to-left, skipping known internal proxies, to find the true client IP. Finally, be careful to default to the last trusted internal IP if no public IP is found in the chain, to avoid collapsing all internal traffic into a single rate-limit bucket.
-**Prevention:** Always use a right-to-left parsing strategy for proxy chains, strictly validating each hop against a whitelist of trusted internal IPs. If a connection doesn't originate from a trusted proxy, fallback immediately to `getRemoteAddr()`. Implement robust test cases that cover external connections, single proxies, multiple internal proxies, and internal clients traversing internal proxies.
+## 2026-09-11 - DoS Risk in Rate Limiter via Unbounded Memory
+**Vulnerability:** The RateLimitFilter stored client IP rate-limiting data in an unbounded `ConcurrentHashMap`. An attacker could spoof numerous IPs or target many arbitrary buckets to infinitely grow the map, leading to memory exhaustion (OOM) and DoS.
+**Learning:** Using basic Maps for caches or temporary tracking structures without strict bounds or eviction mechanisms in long-running applications poses significant DoS and stability risks.
+**Prevention:** Always use proper caching libraries (like Caffeine or Guava) with strict bounds (`maximumSize`) and automatic time-based eviction policies (`expireAfterAccess` or `expireAfterWrite`) when tracking dynamic client data in memory.
diff --git a/backend/pom.xml b/backend/pom.xml
index 79e1147..26085a9 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -46,6 +46,10 @@
org.springframework.boot
spring-boot-starter-actuator
+
+ com.github.ben-manes.caffeine
+ caffeine
+
diff --git a/backend/src/main/java/com/algorithmrace/visualizer/RateLimitFilter.java b/backend/src/main/java/com/algorithmrace/visualizer/RateLimitFilter.java
index b12da30..7e049b4 100644
--- a/backend/src/main/java/com/algorithmrace/visualizer/RateLimitFilter.java
+++ b/backend/src/main/java/com/algorithmrace/visualizer/RateLimitFilter.java
@@ -1,11 +1,14 @@
package com.algorithmrace.visualizer;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
@@ -31,8 +34,11 @@ public class RateLimitFilter implements Filter {
private static final int DEFAULT_LIMIT = 60;
private static final long WINDOW_MS = 60_000L;
- // Stores: clientIP -> bucket -> [timestamps]
- private final Map> clients = new ConcurrentHashMap<>();
+ // Stores: clientIP -> bucket -> SlidingWindow
+ // Cache auto-evicts entire SlidingWindow objects 1 minute after their last access,
+ // preventing unbounded memory growth (OOM DoS) from many unique IPs or buckets.
+ private final Cache> clients =
+ Caffeine.newBuilder().expireAfterAccess(1, TimeUnit.MINUTES).maximumSize(100_000).build();
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
@@ -55,10 +61,8 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
String bucket = resolveBucket(path);
int limit = resolveLimit(path);
- SlidingWindow window =
- clients
- .computeIfAbsent(clientIp, k -> new ConcurrentHashMap<>())
- .computeIfAbsent(bucket, k -> new SlidingWindow());
+ Map userBuckets = clients.get(clientIp, k -> new ConcurrentHashMap<>());
+ SlidingWindow window = userBuckets.computeIfAbsent(bucket, k -> new SlidingWindow());
if (!window.tryAcquire(limit)) {
log.warn("Rate limit exceeded for IP={} bucket={}", clientIp, bucket);