From 68cb20edba365c866cdd85b9653705a379e17f29 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 18 Jul 2026 11:45:13 +0000 Subject: [PATCH] feat(security): add per-IP rate limiting on guest-order endpoints POST /api/guest-orders is fully public (permitAll) with no captcha, throttling, or IP-based limiting. An attacker can flood the orders table (DB bloat / DoS) and mass-generate self-confirmed paid orders. Changes: - GuestOrderRateLimitFilter: in-memory sliding-window rate limiter (OncePerRequestFilter + @Component). Per-IP limits: POST /api/guest-orders: 5 req/min (configurable) Other /api/guest-orders/**: 20 req/min (configurable) Returns 429 with Swedish JSON error when exceeded. Respects X-Forwarded-For and X-Real-IP headers. - SecurityConfig: register the filter before JWT filter - GuestOrderControllerTest: set high limits (1000) and reset filter state in @BeforeEach to avoid cross-test interference Config via application properties: app.rate-limit.guest-create (default 5) app.rate-limit.guest-default (default 20) Phase 0 interim: in-memory, per-JVM, resets on restart. For production, use Bucket4j + Redis or nginx limit_req. Closes #19 --- .../se/bilhalsning/config/SecurityConfig.java | 5 +- .../security/GuestOrderRateLimitFilter.java | 123 ++++++++++++++++++ .../controller/GuestOrderControllerTest.java | 14 +- 3 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/se/bilhalsning/security/GuestOrderRateLimitFilter.java diff --git a/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java b/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java index 1150ad2..ff84149 100644 --- a/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java +++ b/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java @@ -15,6 +15,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import se.bilhalsning.dto.ErrorResponse; +import se.bilhalsning.security.GuestOrderRateLimitFilter; import se.bilhalsning.security.JwtAuthenticationFilter; import se.bilhalsning.security.JwtService; @@ -41,7 +42,8 @@ public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, - JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { + JwtAuthenticationFilter jwtAuthenticationFilter, + GuestOrderRateLimitFilter guestOrderRateLimitFilter) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) @@ -64,6 +66,7 @@ public class SecurityConfig { writeError(response, HttpStatus.UNAUTHORIZED, UNAUTHENTICATED_MESSAGE)) .accessDeniedHandler((request, response, ex) -> writeError(response, HttpStatus.FORBIDDEN, FORBIDDEN_MESSAGE))) + .addFilterBefore(guestOrderRateLimitFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/backend/src/main/java/se/bilhalsning/security/GuestOrderRateLimitFilter.java b/backend/src/main/java/se/bilhalsning/security/GuestOrderRateLimitFilter.java new file mode 100644 index 0000000..7a0c205 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/security/GuestOrderRateLimitFilter.java @@ -0,0 +1,123 @@ +package se.bilhalsning.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * In-memory per-IP rate limiter for public guest-order endpoints. + * + *

Uses a sliding-window log algorithm: for each client IP, stores request + * timestamps in a deque. On each request, timestamps older than the window + * are pruned. If the remaining count exceeds the limit, the request is + * rejected with HTTP 429. + * + *

This is a Phase 0 interim measure. It is not suitable for multi-instance + * deployments (state is per-JVM) and resets on restart. For production, use + * Bucket4j with a Redis backing or a reverse proxy (nginx {@code limit_req}). + * + *

Configurable via application properties: + *

+ */ +@Component +public class GuestOrderRateLimitFilter extends OncePerRequestFilter { + + @Value("${app.rate-limit.guest-create:5}") + private int createLimit; + + @Value("${app.rate-limit.guest-default:20}") + private int defaultLimit; + + private static final long WINDOW_SECONDS = 60; + + private final ConcurrentHashMap> store = new ConcurrentHashMap<>(); + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String path = request.getRequestURI(); + if (!path.startsWith("/api/guest-orders")) { + filterChain.doFilter(request, response); + return; + } + + String ip = extractClientIp(request); + String key = ip + ":" + (isCreatePath(path) ? "create" : "default"); + int limit = isCreatePath(path) ? createLimit : defaultLimit; + + if (isRateLimited(key, limit)) { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write( + "{\"message\":\"För många förfrågningar. Försök igen om en minut.\"}"); + return; + } + + filterChain.doFilter(request, response); + } + + /** + * Check and record a request. Returns true if the request should be + * rejected (rate limit exceeded), false otherwise. + */ + private boolean isRateLimited(String key, int limit) { + Instant now = Instant.now(); + Instant cutoff = now.minusSeconds(WINDOW_SECONDS); + + Deque timestamps = store.computeIfAbsent(key, k -> new ArrayDeque<>()); + + synchronized (timestamps) { + while (!timestamps.isEmpty() && timestamps.peekFirst().isBefore(cutoff)) { + timestamps.pollFirst(); + } + + if (timestamps.size() >= limit) { + return true; + } + + timestamps.addLast(now); + return false; + } + } + + private boolean isCreatePath(String path) { + return path.equals("/api/guest-orders"); + } + + private String extractClientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].trim(); + } + String realIp = request.getHeader("X-Real-IP"); + if (realIp != null && !realIp.isBlank()) { + return realIp.trim(); + } + return request.getRemoteAddr(); + } + + /** + * Reset the rate limit state. For testing only. + */ + public void reset() { + store.clear(); + } +} diff --git a/backend/src/test/java/se/bilhalsning/controller/GuestOrderControllerTest.java b/backend/src/test/java/se/bilhalsning/controller/GuestOrderControllerTest.java index 2ec009d..a822f83 100644 --- a/backend/src/test/java/se/bilhalsning/controller/GuestOrderControllerTest.java +++ b/backend/src/test/java/se/bilhalsning/controller/GuestOrderControllerTest.java @@ -23,7 +23,11 @@ import se.bilhalsning.service.UserService; @SpringBootTest @AutoConfigureMockMvc -@TestPropertySource(properties = "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!") +@TestPropertySource(properties = { + "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!", + "app.rate-limit.guest-create=1000", + "app.rate-limit.guest-default=1000" +}) class GuestOrderControllerTest { @Autowired @@ -35,6 +39,14 @@ class GuestOrderControllerTest { @MockitoBean private UserService userService; + @Autowired + private se.bilhalsning.security.GuestOrderRateLimitFilter rateLimitFilter; + + @org.junit.jupiter.api.BeforeEach + void resetRateLimit() { + rateLimitFilter.reset(); + } + // --- POST /api/guest-orders (create) --- @Test