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
This commit is contained in:
parent
48c2a50c5d
commit
68cb20edba
3 changed files with 140 additions and 2 deletions
|
|
@ -15,6 +15,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
import se.bilhalsning.dto.ErrorResponse;
|
import se.bilhalsning.dto.ErrorResponse;
|
||||||
|
import se.bilhalsning.security.GuestOrderRateLimitFilter;
|
||||||
import se.bilhalsning.security.JwtAuthenticationFilter;
|
import se.bilhalsning.security.JwtAuthenticationFilter;
|
||||||
import se.bilhalsning.security.JwtService;
|
import se.bilhalsning.security.JwtService;
|
||||||
|
|
||||||
|
|
@ -41,7 +42,8 @@ public class SecurityConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http,
|
public SecurityFilterChain securityFilterChain(HttpSecurity http,
|
||||||
JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
|
JwtAuthenticationFilter jwtAuthenticationFilter,
|
||||||
|
GuestOrderRateLimitFilter guestOrderRateLimitFilter) throws Exception {
|
||||||
http
|
http
|
||||||
.csrf(csrf -> csrf.disable())
|
.csrf(csrf -> csrf.disable())
|
||||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
|
@ -64,6 +66,7 @@ public class SecurityConfig {
|
||||||
writeError(response, HttpStatus.UNAUTHORIZED, UNAUTHENTICATED_MESSAGE))
|
writeError(response, HttpStatus.UNAUTHORIZED, UNAUTHENTICATED_MESSAGE))
|
||||||
.accessDeniedHandler((request, response, ex) ->
|
.accessDeniedHandler((request, response, ex) ->
|
||||||
writeError(response, HttpStatus.FORBIDDEN, FORBIDDEN_MESSAGE)))
|
writeError(response, HttpStatus.FORBIDDEN, FORBIDDEN_MESSAGE)))
|
||||||
|
.addFilterBefore(guestOrderRateLimitFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,11 @@ import se.bilhalsning.service.UserService;
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@AutoConfigureMockMvc
|
@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 {
|
class GuestOrderControllerTest {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
|
@ -35,6 +39,14 @@ class GuestOrderControllerTest {
|
||||||
@MockitoBean
|
@MockitoBean
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private se.bilhalsning.security.GuestOrderRateLimitFilter rateLimitFilter;
|
||||||
|
|
||||||
|
@org.junit.jupiter.api.BeforeEach
|
||||||
|
void resetRateLimit() {
|
||||||
|
rateLimitFilter.reset();
|
||||||
|
}
|
||||||
|
|
||||||
// --- POST /api/guest-orders (create) ---
|
// --- POST /api/guest-orders (create) ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue