feat(security): add per-IP rate limiting on guest-order endpoints
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m49s
CI / E2E browser tests (pull_request) Failing after 1m45s

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
This commit is contained in:
Hermes Agent 2026-07-18 11:45:13 +00:00
parent 48c2a50c5d
commit 68cb20edba
3 changed files with 140 additions and 2 deletions

View file

@ -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();

View file

@ -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.
*
* <p>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.
*
* <p>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}).
*
* <p>Configurable via application properties:
* <ul>
* <li>{@code app.rate-limit.guest-create} POST /api/guest-orders (default 5/min)</li>
* <li>{@code app.rate-limit.guest-default} all other /api/guest-orders/** (default 20/min)</li>
* </ul>
*/
@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<String, Deque<Instant>> 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<Instant> 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();
}
}

View file

@ -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