From 3a1bd782cd659a9b4a00b115131583d085959dfd Mon Sep 17 00:00:00 2001 From: Lucas Aguiar Date: Mon, 5 Jan 2026 10:05:49 -0300 Subject: [PATCH 1/2] chore(dependencies): add bucket4j --- pom.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pom.xml b/pom.xml index 24d0eec..105aa11 100644 --- a/pom.xml +++ b/pom.xml @@ -124,6 +124,11 @@ stripe-java 31.0.0 + + com.bucket4j + bucket4j_jdk17-core + 8.16.0 + From 1465edae93f75675eb6e9892c47305b6244b9b85 Mon Sep 17 00:00:00 2001 From: Lucas Aguiar Date: Mon, 5 Jan 2026 10:09:13 -0300 Subject: [PATCH 2/2] security(infra): implement rate limiting with penalty system - Add RateLimitFilter with Bucket4j for DDoS protection - Implement 60 requests/minute limit per IP - Add progressive penalty: 5min block after 10 violations - Extract real client IP from proxy headers - Add automatic cache cleanup for memory management - Exclude Stripe webhook from rate limiting --- .../infra/security/RateLimitFilter.java | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 src/main/java/br/com/notehub/infra/security/RateLimitFilter.java diff --git a/src/main/java/br/com/notehub/infra/security/RateLimitFilter.java b/src/main/java/br/com/notehub/infra/security/RateLimitFilter.java new file mode 100644 index 0000000..e54699d --- /dev/null +++ b/src/main/java/br/com/notehub/infra/security/RateLimitFilter.java @@ -0,0 +1,180 @@ +package br.com.notehub.infra.security; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class RateLimitFilter extends OncePerRequestFilter { + + private static final Logger logger = LoggerFactory.getLogger(RateLimitFilter.class); + private static final int MAX_CACHE_SIZE = 10_000; + private static final int REQUESTS_PER_MINUTE = 60; + private static final int PENALTY_THRESHOLD = 10; + private static final long PENALTY_DURATION_MS = Duration.ofMinutes(5).toMillis(); + + private final Map buckets = new ConcurrentHashMap<>(); + private final Map penalties = new ConcurrentHashMap<>(); + + private static class BucketEntry { + + final Bucket bucket; + long lastAccessTime; + int violationCount; + + BucketEntry(Bucket bucket) { + this.bucket = bucket; + this.lastAccessTime = System.currentTimeMillis(); + this.violationCount = 0; + } + + } + + private record PenaltyEntry(long blockedUntil, int totalViolations) { + + boolean isStillBlocked() { + return System.currentTimeMillis() < blockedUntil; + } + + long remainingSeconds() { + return (blockedUntil - System.currentTimeMillis()) / 1000; + } + + } + + private Bucket createNewBucket() { + return Bucket.builder().addLimit(Bandwidth.simple(REQUESTS_PER_MINUTE, Duration.ofMinutes(1))).build(); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + if (request.getServletPath().contains("/api/v1/payment/stripe/sponsorship/webhook")) { + filterChain.doFilter(request, response); + return; + } + + String clientIp = getClientIp(request); + PenaltyEntry penalty = penalties.get(clientIp); + + if (penalty != null && penalty.isStillBlocked()) { + logger.warn( + "IP {} está bloqueado temporariamente. Restam {} segundos. Total de violações: {}", + clientIp, penalty.remainingSeconds(), penalty.totalViolations + ); + sendPenaltyResponse(response, penalty.remainingSeconds()); + return; + } + + if (penalty != null && !penalty.isStillBlocked()) { + penalties.remove(clientIp); + logger.info("IP {} liberado após cumprir penalty", clientIp); + } + + cleanupIfNeeded(); + + BucketEntry entry = buckets.computeIfAbsent(clientIp, ip -> new BucketEntry(createNewBucket())); + entry.lastAccessTime = System.currentTimeMillis(); + + if (entry.bucket.tryConsume(1)) { + entry.violationCount = Math.max(0, entry.violationCount - 1); + filterChain.doFilter(request, response); + } else { + entry.violationCount++; + logger.warn( + "Rate limit excedido para IP: {} no endpoint: {} (violação #{})", + clientIp, request.getRequestURI(), entry.violationCount + ); + if (entry.violationCount >= PENALTY_THRESHOLD) { + long blockedUntil = System.currentTimeMillis() + PENALTY_DURATION_MS; + penalties.put(clientIp, new PenaltyEntry(blockedUntil, entry.violationCount)); + logger.error( + "IP {} BLOQUEADO por 5 minutos após {} violações consecutivas", + clientIp, entry.violationCount + ); + sendPenaltyResponse(response, PENALTY_DURATION_MS / 1000); + } else sendRateLimitResponse(response, entry.violationCount); + } + + } + + private String getClientIp(HttpServletRequest request) { + String[] headers = { + "X-Forwarded-For", + "X-Real-IP", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_X_FORWARDED_FOR", + "HTTP_X_FORWARDED", + "HTTP_CLIENT_IP" + }; + for (String header : headers) { + String ip = request.getHeader(header); + if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { + return ip.split(",")[0].trim(); + } + } + return request.getRemoteAddr(); + } + + private void cleanupIfNeeded() { + if (buckets.size() > MAX_CACHE_SIZE) { + long now = System.currentTimeMillis(); + long tenMinutesAgo = now - Duration.ofMinutes(10).toMillis(); + buckets.entrySet().removeIf(entry -> + entry.getValue().lastAccessTime < tenMinutesAgo + ); + logger.info("Cache de rate limit limpo. Tamanho atual: {}", buckets.size()); + } + penalties.entrySet().removeIf(entry -> !entry.getValue().isStillBlocked()); + } + + private void sendRateLimitResponse(HttpServletResponse response, int violationCount) throws IOException { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(""" + { + "error": "rate_limit", + "message": "Muitas requisições. Tente novamente mais tarde.", + "limite": %d, + "periodo": "1 minuto", + "violacoes": %d, + "aviso": "Após %d violações consecutivas, você será bloqueado por 5 minutos" + } + """.formatted(REQUESTS_PER_MINUTE, violationCount, PENALTY_THRESHOLD)); + } + + private void sendPenaltyResponse(HttpServletResponse response, long remainingSeconds) throws IOException { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.setHeader("Retry-After", String.valueOf(remainingSeconds)); + response.getWriter().write(""" + { + "error": "temporarily_blocked", + "message": "Você foi temporariamente bloqueado devido a múltiplas violações de rate limit.", + "blocked_for_seconds": %d, + "retry_after": "%d minutos" + } + """.formatted(remainingSeconds, remainingSeconds / 60)); + } + +} \ No newline at end of file