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:
+ *
+ * - {@code app.rate-limit.guest-create} — POST /api/guest-orders (default 5/min)
+ * - {@code app.rate-limit.guest-default} — all other /api/guest-orders/** (default 20/min)
+ *
+ */
+@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