Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions backend/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<String, Map<String, SlidingWindow>> 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<String, Map<String, SlidingWindow>> clients =
Caffeine.newBuilder().expireAfterAccess(1, TimeUnit.MINUTES).maximumSize(100_000).build();

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
Expand All @@ -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<String, SlidingWindow> 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
package com.algorithmrace.visualizer.algorithms.pathfinding;

import java.util.ArrayDeque;
import java.util.HashSet;
import java.util.Queue;
import java.util.Set;

public class BellmanFordModel extends PathfindingModel {
private final Queue<GridCell> queue = new ArrayDeque<>();
// O(1) lookup instead of O(n) queue.contains()
private final Set<GridCell> inQueue = new HashSet<>();
private boolean[][] inQueue;

public BellmanFordModel() {
super("Bellman-Ford");
}

@Override
public void initGrid(int rows, int cols) {
super.initGrid(rows, cols);
inQueue = new boolean[rows][cols];
}

@Override
public void step() {
if (isDone() || queue.isEmpty()) {
Expand All @@ -25,7 +28,7 @@ public void step() {
}

GridCell current = queue.poll();
inQueue.remove(current);
inQueue[current.row][current.col] = false;

if (current == end) {
reconstructPath(end);
Expand All @@ -46,9 +49,9 @@ public void step() {
if (nb.state == CellState.EMPTY) {
nb.state = CellState.FRONTIER;
}
if (!inQueue.contains(nb)) {
if (!inQueue[nb.row][nb.col]) {
queue.add(nb);
inQueue.add(nb);
inQueue[nb.row][nb.col] = true;
}
}
}
Expand All @@ -57,12 +60,20 @@ public void step() {
@Override
public void reset() {
queue.clear();
inQueue.clear();
if (inQueue != null) {
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
inQueue[r][c] = false;
}
}
}
resetStats();
if (start != null) {
start.gCost = 0;
queue.add(start);
inQueue.add(start);
if (inQueue != null) {
inQueue[start.row][start.col] = true;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.PriorityQueue;
import java.util.Set;

public class JPSModel extends PathfindingModel {

private final PriorityQueue<GridCell> openSet =
new PriorityQueue<>(Comparator.comparingDouble(GridCell::fCost));
private final Set<GridCell> inOpenSet = new HashSet<>();

public JPSModel() {
super("Jump Point Search");
Expand All @@ -27,6 +30,9 @@ public void step() {
return;
}
GridCell current = openSet.poll();
if (current != null) {
inOpenSet.remove(current);
}
if (current == end) {
reconstructPath(end);
markDone();
Expand All @@ -49,8 +55,9 @@ public void step() {
if (nb.state == CellState.EMPTY || nb.state == CellState.VISITED) {
nb.state = CellState.FRONTIER;
}
if (!openSet.contains(nb)) {
if (!inOpenSet.contains(nb)) {
openSet.add(nb);
inOpenSet.add(nb);
}
}
}
Expand Down Expand Up @@ -232,10 +239,12 @@ protected void reconstructPath(GridCell endCell) {
@Override
public void reset() {
openSet.clear();
inOpenSet.clear();
resetStats();
if (start != null) {
start.gCost = 0;
openSet.add(start);
inOpenSet.add(start);
}
}
}
Loading
Loading