From 0f011167760f770ebfa7f7de3c247d47f54b13b0 Mon Sep 17 00:00:00 2001 From: Dejan Zdravkovic Date: Sat, 21 Feb 2026 09:45:22 +0100 Subject: [PATCH 01/10] rate limiter --- CLAUDE.md | 3 +- backend/spring-boot/pom.xml | 10 ++ .../api/auth/controller/AuthController.java | 8 + .../api/shared/config/WebMvcConfig.java | 13 +- .../exception/TooManyRequestsException.java | 16 ++ .../handling/CustomExceptionHandler.java | 7 + .../api/shared/ratelimit/RateLimit.java | 14 ++ .../ratelimit/RateLimitInterceptor.java | 95 +++++++++++ .../user/controller/ProfileController.java | 2 + .../api/user/controller/UserController.java | 3 + ...itional-spring-configuration-metadata.json | 5 + .../src/main/resources/error-codes.properties | 1 + .../src/main/resources/static/openapi.yml | 37 +++++ .../api/shared/integration/RateLimitIT.java | 147 ++++++++++++++++++ .../src/test/resources/application.yml | 3 + docs/src/content/how-it-works/_meta.js | 1 + .../content/how-it-works/rate-limiting.mdx | 52 +++++++ 17 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 backend/spring-boot/src/main/java/org/bugzkit/api/shared/error/exception/TooManyRequestsException.java create mode 100644 backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimit.java create mode 100644 backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java create mode 100644 backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/RateLimitIT.java create mode 100644 docs/src/content/how-it-works/rate-limiting.mdx diff --git a/CLAUDE.md b/CLAUDE.md index a2765372..cea97324 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ Organized by feature module, each with controller/service/repository/payload lay - **`auth/`** — JWT authentication, OAuth2 (Google), token management (access, refresh, verify-email, reset-password). Tokens stored in Redis. JWT sent via HTTP-only cookies. - **`user/`** — User/Role JPA entities, repositories, MapStruct mappers for DTO conversion. - **`admin/`** — Admin user management endpoints. -- **`shared/`** — Cross-cutting: security config (`SecurityConfig.java`), error handling (standardized error codes in `error-codes.properties` mapped to frontend i18n keys), email service (MJML templates), data initialization (`DataInit.java`), Redis config, i18n messages. +- **`shared/`** — Cross-cutting: security config (`SecurityConfig.java`), error handling (standardized error codes in `error-codes.properties` mapped to frontend i18n keys), email service (MJML templates), data initialization (`DataInit.java`), Redis config, i18n messages, rate limiting (`shared/ratelimit/`). Security: Stateless sessions, JWT filter chain, role-based access (ADMIN/USER). Public endpoints are whitelisted in `SecurityConfig.java`; everything else requires authentication. @@ -58,6 +58,7 @@ Security: Stateless sessions, JWT filter chain, role-based access (ADMIN/USER). - Device revocation (`DeviceService.revoke()`) intentionally invalidates **all** access tokens via `UserBlacklist`, not just the revoked device's token. Active sessions auto-refresh silently via their refresh tokens. This is by design to avoid an extra Redis lookup on every request. - Access token validation (`AccessTokenService.check()`) runs on every authenticated request — keep it minimal (JWT verify + blacklist check + user blacklist check). - Token data (userId, roles, deviceId) must only be extracted from a JWT **after** signature verification. +- **Rate limiting** uses Bucket4j (token bucket algorithm) with per-IP Caffeine-cached buckets. Apply the `@RateLimit(requests, duration)` annotation to controller methods. Configurable via `rate-limit.enabled` (defaults to `true`, disabled in tests). Rate-limited endpoints return `429 Too Many Requests` with a `Retry-After` header. ### Frontend (`frontend/svelte-kit/src/`) diff --git a/backend/spring-boot/pom.xml b/backend/spring-boot/pom.xml index 8c36121d..3702702a 100644 --- a/backend/spring-boot/pom.xml +++ b/backend/spring-boot/pom.xml @@ -25,6 +25,7 @@ 7.2.1 3.0.1 2.5.4 + 8.16.1 0.8.14 3.15.0 3.2.1 @@ -101,6 +102,15 @@ jedis ${jedis.version} + + com.bucket4j + bucket4j_jdk17-core + ${bucket4j.version} + + + com.github.ben-manes.caffeine + caffeine + org.springdoc springdoc-openapi-starter-webmvc-ui diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java index 2cbbb17a..4ebb1ac6 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/auth/controller/AuthController.java @@ -16,6 +16,7 @@ import org.bugzkit.api.auth.util.AuthUtil; import org.bugzkit.api.auth.util.JwtUtil; import org.bugzkit.api.shared.constants.Path; +import org.bugzkit.api.shared.ratelimit.RateLimit; import org.bugzkit.api.user.payload.dto.UserDTO; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; @@ -49,12 +50,14 @@ public AuthController(AuthService authService, DeviceService deviceService) { this.deviceService = deviceService; } + @RateLimit(requests = 5, duration = 60) @PostMapping("/register") public ResponseEntity register( @Valid @RequestBody RegisterUserRequest registerUserRequest) { return new ResponseEntity<>(authService.register(registerUserRequest), HttpStatus.CREATED); } + @RateLimit(requests = 10, duration = 60) @PostMapping("/tokens") public ResponseEntity authenticate( @Valid @RequestBody AuthTokensRequest authTokensRequest, HttpServletRequest request) { @@ -90,6 +93,7 @@ public ResponseEntity revokeDevice(@PathVariable String deviceId) { return ResponseEntity.noContent().build(); } + @RateLimit(requests = 10, duration = 60) @PostMapping("/tokens/refresh") public ResponseEntity refreshTokens(HttpServletRequest request) { final var refreshToken = AuthUtil.getValueFromCookie("refreshToken", request); @@ -98,6 +102,7 @@ public ResponseEntity refreshTokens(HttpServletRequest request) { return setAuthCookies(authTokens); } + @RateLimit(requests = 3, duration = 60) @PostMapping("/password/forgot") public ResponseEntity forgotPassword( @Valid @RequestBody ForgotPasswordRequest forgotPasswordRequest) { @@ -105,6 +110,7 @@ public ResponseEntity forgotPassword( return ResponseEntity.noContent().build(); } + @RateLimit(requests = 5, duration = 60) @PostMapping("/password/reset") public ResponseEntity resetPassword( @Valid @RequestBody ResetPasswordRequest resetPasswordRequest) { @@ -112,6 +118,7 @@ public ResponseEntity resetPassword( return ResponseEntity.noContent().build(); } + @RateLimit(requests = 3, duration = 60) @PostMapping("/verification-email") public ResponseEntity sendVerificationMail( @Valid @RequestBody VerificationEmailRequest verificationEmailRequest) { @@ -119,6 +126,7 @@ public ResponseEntity sendVerificationMail( return ResponseEntity.noContent().build(); } + @RateLimit(requests = 5, duration = 60) @PostMapping("/verify-email") public ResponseEntity verifyEmail( @Valid @RequestBody VerifyEmailRequest verifyEmailRequest) { diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/WebMvcConfig.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/WebMvcConfig.java index 9bd77abf..ad67a199 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/WebMvcConfig.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/WebMvcConfig.java @@ -1,6 +1,7 @@ package org.bugzkit.api.shared.config; import org.bugzkit.api.shared.interceptor.RequestInterceptor; +import org.bugzkit.api.shared.ratelimit.RateLimitInterceptor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; @@ -12,6 +13,15 @@ public class WebMvcConfig implements WebMvcConfigurer { @Value("${ui.url}") private String uiUrl; + private final RateLimitInterceptor rateLimitInterceptor; + private final RequestInterceptor requestInterceptor; + + public WebMvcConfig( + RateLimitInterceptor rateLimitInterceptor, RequestInterceptor requestInterceptor) { + this.rateLimitInterceptor = rateLimitInterceptor; + this.requestInterceptor = requestInterceptor; + } + @Override public void addCorsMappings(CorsRegistry registry) { registry @@ -24,6 +34,7 @@ public void addCorsMappings(CorsRegistry registry) { @Override public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(new RequestInterceptor()); + registry.addInterceptor(rateLimitInterceptor); + registry.addInterceptor(requestInterceptor); } } diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/error/exception/TooManyRequestsException.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/error/exception/TooManyRequestsException.java new file mode 100644 index 00000000..4894a045 --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/error/exception/TooManyRequestsException.java @@ -0,0 +1,16 @@ +package org.bugzkit.api.shared.error.exception; + +import java.io.Serial; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class TooManyRequestsException extends RuntimeException { + @Serial private static final long serialVersionUID = -2937654540916338509L; + private final HttpStatus status; + + public TooManyRequestsException(String message) { + super(message); + this.status = HttpStatus.TOO_MANY_REQUESTS; + } +} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/error/handling/CustomExceptionHandler.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/error/handling/CustomExceptionHandler.java index 9b5e0a76..dfd532b3 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/error/handling/CustomExceptionHandler.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/error/handling/CustomExceptionHandler.java @@ -5,6 +5,7 @@ import org.bugzkit.api.shared.error.exception.BadRequestException; import org.bugzkit.api.shared.error.exception.ConflictException; import org.bugzkit.api.shared.error.exception.ResourceNotFoundException; +import org.bugzkit.api.shared.error.exception.TooManyRequestsException; import org.bugzkit.api.shared.error.exception.UnauthorizedException; import org.bugzkit.api.shared.logger.CustomLogger; import org.bugzkit.api.shared.message.service.MessageService; @@ -88,6 +89,12 @@ public ResponseEntity handleConflictException(ConflictException e) { return createError(e.getStatus(), messageService.getMessage(e.getMessage())); } + @ExceptionHandler({TooManyRequestsException.class}) + public ResponseEntity handleTooManyRequestsException(TooManyRequestsException e) { + customLogger.error("Too many requests", e); + return createError(e.getStatus(), messageService.getMessage(e.getMessage())); + } + @ExceptionHandler({AuthenticationException.class}) public ResponseEntity handleAuthenticationException(AuthenticationException e) { if (e instanceof DisabledException) { diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimit.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimit.java new file mode 100644 index 00000000..04159b03 --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimit.java @@ -0,0 +1,14 @@ +package org.bugzkit.api.shared.ratelimit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RateLimit { + int requests(); + + int duration(); +} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java new file mode 100644 index 00000000..867d89c4 --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java @@ -0,0 +1,95 @@ +package org.bugzkit.api.shared.ratelimit; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.ConsumptionProbe; +import jakarta.annotation.Nonnull; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import org.bugzkit.api.shared.error.ErrorMessage; +import org.bugzkit.api.shared.message.service.MessageService; +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.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class RateLimitInterceptor implements HandlerInterceptor { + private final MessageService messageService; + private final boolean enabled; + private final Map> caches = new ConcurrentHashMap<>(); + + public RateLimitInterceptor( + MessageService messageService, @Value("${rate-limit.enabled:true}") boolean enabled) { + this.messageService = messageService; + this.enabled = enabled; + } + + @Override + public boolean preHandle( + @Nonnull HttpServletRequest request, + @Nonnull HttpServletResponse response, + @Nonnull Object handler) + throws Exception { + if (!enabled) return true; + if (!(handler instanceof HandlerMethod handlerMethod)) return true; + final var rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class); + if (rateLimit == null) return true; + + final var key = resolveClientIp(request); + final var cacheKey = + handlerMethod.getBeanType().getSimpleName() + ":" + handlerMethod.getMethod().getName(); + final var cache = + caches.computeIfAbsent( + cacheKey, + k -> + Caffeine.newBuilder() + .expireAfterAccess(rateLimit.duration() + 60, TimeUnit.SECONDS) + .build()); + final var bucket = cache.get(key, k -> createBucket(rateLimit)); + final var probe = bucket.tryConsumeAndReturnRemaining(1); + + if (probe.isConsumed()) return true; + + writeRateLimitResponse(response, probe); + return false; + } + + private Bucket createBucket(RateLimit rateLimit) { + final var bandwidth = + Bandwidth.builder() + .capacity(rateLimit.requests()) + .refillGreedy(rateLimit.requests(), Duration.ofSeconds(rateLimit.duration())) + .build(); + return Bucket.builder().addLimit(bandwidth).build(); + } + + private String resolveClientIp(HttpServletRequest request) { + final var forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isEmpty()) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } + + private void writeRateLimitResponse(HttpServletResponse response, ConsumptionProbe probe) + throws Exception { + final var retryAfter = TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill()); + final var status = HttpStatus.TOO_MANY_REQUESTS; + final var errorMessage = new ErrorMessage(status); + errorMessage.addCode(messageService.getMessage("request.tooManyRequests")); + + response.setStatus(status.value()); + response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE); + response.setHeader("Retry-After", String.valueOf(retryAfter)); + response.getWriter().write(errorMessage.toString()); + } +} diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/user/controller/ProfileController.java b/backend/spring-boot/src/main/java/org/bugzkit/api/user/controller/ProfileController.java index dcb48d88..e4902fd7 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/user/controller/ProfileController.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/user/controller/ProfileController.java @@ -2,6 +2,7 @@ import jakarta.validation.Valid; import org.bugzkit.api.shared.constants.Path; +import org.bugzkit.api.shared.ratelimit.RateLimit; import org.bugzkit.api.user.payload.dto.UserDTO; import org.bugzkit.api.user.payload.request.ChangePasswordRequest; import org.bugzkit.api.user.payload.request.PatchProfileRequest; @@ -40,6 +41,7 @@ public ResponseEntity delete() { return ResponseEntity.noContent().build(); } + @RateLimit(requests = 5, duration = 60) @PatchMapping("/password") public ResponseEntity changePassword( @Valid @RequestBody ChangePasswordRequest changePasswordRequest) { diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/user/controller/UserController.java b/backend/spring-boot/src/main/java/org/bugzkit/api/user/controller/UserController.java index 2676cb3c..a6106f1e 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/user/controller/UserController.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/user/controller/UserController.java @@ -4,6 +4,7 @@ import org.bugzkit.api.shared.constants.Path; import org.bugzkit.api.shared.payload.dto.AvailabilityDTO; import org.bugzkit.api.shared.payload.dto.PageableDTO; +import org.bugzkit.api.shared.ratelimit.RateLimit; import org.bugzkit.api.user.payload.dto.UserDTO; import org.bugzkit.api.user.payload.request.EmailAvailabilityRequest; import org.bugzkit.api.user.payload.request.UsernameAvailabilityRequest; @@ -41,12 +42,14 @@ public ResponseEntity findByUsername(@PathVariable("username") String u return ResponseEntity.ok(userService.findByUsername(username)); } + @RateLimit(requests = 10, duration = 60) @PostMapping("/username/availability") public ResponseEntity usernameAvailability( @Valid @RequestBody UsernameAvailabilityRequest usernameAvailabilityRequest) { return ResponseEntity.ok(userService.usernameAvailability(usernameAvailabilityRequest)); } + @RateLimit(requests = 10, duration = 60) @PostMapping("/email/availability") public ResponseEntity emailAvailability( @Valid @RequestBody EmailAvailabilityRequest emailAvailabilityRequest) { diff --git a/backend/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/backend/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json index d5e773d9..b00c40b4 100644 --- a/backend/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/backend/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -39,6 +39,11 @@ "name": "jwt.reset-password-token.duration", "type": "java.lang.String", "description": "Reset password token duration time." + }, + { + "name": "rate-limit.enabled", + "type": "java.lang.Boolean", + "description": "Enable or disable rate limiter." } ] } diff --git a/backend/spring-boot/src/main/resources/error-codes.properties b/backend/spring-boot/src/main/resources/error-codes.properties index 24b359c0..f188c74d 100644 --- a/backend/spring-boot/src/main/resources/error-codes.properties +++ b/backend/spring-boot/src/main/resources/error-codes.properties @@ -24,6 +24,7 @@ user.lock=API_ERROR_USER_LOCK user.rolesEmpty=API_ERROR_USER_ROLES_EMPTY user.roleNotFound=API_ERROR_USER_ROLE_NOT_FOUND +request.tooManyRequests=API_ERROR_REQUEST_TOO_MANY_REQUESTS request.parameterMissing=API_ERROR_REQUEST_PARAMETER_MISSING request.methodNotSupported=API_ERROR_REQUEST_METHOD_NOT_SUPPORTED request.messageNotReadable=API_ERROR_REQUEST_MESSAGE_NOT_READABLE diff --git a/backend/spring-boot/src/main/resources/static/openapi.yml b/backend/spring-boot/src/main/resources/static/openapi.yml index 930576e2..e42d40e3 100644 --- a/backend/spring-boot/src/main/resources/static/openapi.yml +++ b/backend/spring-boot/src/main/resources/static/openapi.yml @@ -47,6 +47,8 @@ paths: $ref: "#/components/responses/BadRequest" 409: $ref: "#/components/responses/Conflict" + 429: + $ref: "#/components/responses/TooManyRequests" /auth/tokens: post: tags: @@ -72,6 +74,8 @@ paths: $ref: "#/components/responses/Unauthorized" 403: $ref: "#/components/responses/Forbidden" + 429: + $ref: "#/components/responses/TooManyRequests" delete: tags: - auth @@ -178,6 +182,8 @@ paths: - "refreshToken=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJyb2xlcyI6WyJVU0VSIl0sImlzc3VlZEF0IjoiMjAyMS0xMC0xOVQxMzowMDowMy45MDYzNDQyMDJaIiwiZXhwIjoxNjM1MjUzMjAzLCJ1c2VySWQiOjJ9.RHzh6qyGJEKYdvCuCF7wPoUGBSrDGeoY8dSTBhuv21Fzw_CPEa5KeI3MOYgSN3zA1o_ZlKwjHgpSsPM3xAO_DQ; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800" 400: $ref: "#/components/responses/BadRequest" + 429: + $ref: "#/components/responses/TooManyRequests" /auth/password/forgot: post: tags: @@ -188,6 +194,8 @@ paths: responses: 204: description: Forgot password email sent successfully + 429: + $ref: "#/components/responses/TooManyRequests" /auth/password/reset: post: tags: @@ -200,6 +208,8 @@ paths: description: Password reset successfully 400: $ref: "#/components/responses/BadRequest" + 429: + $ref: "#/components/responses/TooManyRequests" /auth/verification-email: post: tags: @@ -210,6 +220,8 @@ paths: responses: 204: description: Verification email sent successfully + 429: + $ref: "#/components/responses/TooManyRequests" /auth/verify-email: post: tags: @@ -222,6 +234,8 @@ paths: description: Email verified successfully 400: $ref: "#/components/responses/BadRequest" + 429: + $ref: "#/components/responses/TooManyRequests" /profile: get: tags: @@ -296,6 +310,8 @@ paths: $ref: "#/components/responses/BadRequest" 401: $ref: "#/components/responses/Unauthorized" + 429: + $ref: "#/components/responses/TooManyRequests" /users: get: tags: @@ -397,6 +413,8 @@ paths: available: true 400: $ref: "#/components/responses/BadRequest" + 429: + $ref: "#/components/responses/TooManyRequests" /users/email/availability: post: tags: @@ -415,6 +433,8 @@ paths: available: true 400: $ref: "#/components/responses/BadRequest" + 429: + $ref: "#/components/responses/TooManyRequests" /roles: get: tags: @@ -711,6 +731,23 @@ components: error: Conflict codes: - API_ERROR_1 + TooManyRequests: + description: Too Many Requests + headers: + Retry-After: + schema: + type: integer + description: Seconds until the rate limit resets + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorMessage" + example: + timestamp: 2023-05-18T00:51:50.758738 + status: 429 + error: Too Many Requests + codes: + - API_ERROR_REQUEST_TOO_MANY_REQUESTS parameters: id: name: id diff --git a/backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/RateLimitIT.java b/backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/RateLimitIT.java new file mode 100644 index 00000000..ca6e8161 --- /dev/null +++ b/backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/RateLimitIT.java @@ -0,0 +1,147 @@ +package org.bugzkit.api.shared.integration; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.bugzkit.api.auth.payload.request.ForgotPasswordRequest; +import org.bugzkit.api.auth.payload.request.VerificationEmailRequest; +import org.bugzkit.api.shared.config.DatabaseContainers; +import org.bugzkit.api.shared.constants.Path; +import org.bugzkit.api.shared.email.service.EmailService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import tools.jackson.databind.ObjectMapper; + +@DirtiesContext +@AutoConfigureMockMvc +@ActiveProfiles("test") +@SpringBootTest +@TestPropertySource(properties = "rate-limit.enabled=true") +class RateLimitIT extends DatabaseContainers { + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @MockitoBean private EmailService emailService; + + @Test + void forgotPassword_withinLimitSucceeds_exceedLimitReturns429() throws Exception { + final var body = objectMapper.writeValueAsString(new ForgotPasswordRequest("test@example.com")); + + // 3 requests within limit should succeed + for (int i = 0; i < 3; i++) { + mockMvc + .perform( + post(Path.AUTH + "/password/forgot") + .header("X-Forwarded-For", "10.0.0.1") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isNoContent()); + } + + // 4th request should be rate limited + mockMvc + .perform( + post(Path.AUTH + "/password/forgot") + .header("X-Forwarded-For", "10.0.0.1") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isTooManyRequests()) + .andExpect(content().string(containsString("API_ERROR_REQUEST_TOO_MANY_REQUESTS"))) + .andExpect(header().exists("Retry-After")); + } + + @Test + void rateLimitedEndpoints_haveIndependentBuckets() throws Exception { + final var forgotBody = + objectMapper.writeValueAsString(new ForgotPasswordRequest("test@example.com")); + final var verificationBody = + objectMapper.writeValueAsString(new VerificationEmailRequest("test@example.com")); + + // Exhaust forgot-password limit (3 requests) + for (int i = 0; i < 3; i++) { + mockMvc + .perform( + post(Path.AUTH + "/password/forgot") + .header("X-Forwarded-For", "10.0.0.2") + .contentType(MediaType.APPLICATION_JSON) + .content(forgotBody)) + .andExpect(status().isNoContent()); + } + + // Forgot password is exhausted + mockMvc + .perform( + post(Path.AUTH + "/password/forgot") + .header("X-Forwarded-For", "10.0.0.2") + .contentType(MediaType.APPLICATION_JSON) + .content(forgotBody)) + .andExpect(status().isTooManyRequests()); + + // Verification email should still work (separate bucket) + mockMvc + .perform( + post(Path.AUTH + "/verification-email") + .header("X-Forwarded-For", "10.0.0.2") + .contentType(MediaType.APPLICATION_JSON) + .content(verificationBody)) + .andExpect(status().isNoContent()); + } + + @Test + void rateLimitIsPerIp_differentIpsHaveIndependentBuckets() throws Exception { + final var body = objectMapper.writeValueAsString(new ForgotPasswordRequest("test@example.com")); + + // Exhaust limit for IP 10.0.0.3 + for (int i = 0; i < 3; i++) { + mockMvc + .perform( + post(Path.AUTH + "/password/forgot") + .header("X-Forwarded-For", "10.0.0.3") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isNoContent()); + } + + // 10.0.0.3 is rate limited + mockMvc + .perform( + post(Path.AUTH + "/password/forgot") + .header("X-Forwarded-For", "10.0.0.3") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isTooManyRequests()); + + // 10.0.0.4 should still work (different IP, different bucket) + mockMvc + .perform( + post(Path.AUTH + "/password/forgot") + .header("X-Forwarded-For", "10.0.0.4") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isNoContent()); + } + + @Test + void endpointWithoutRateLimit_neverThrottled() throws Exception { + // DELETE /auth/tokens has no @RateLimit annotation + for (int i = 0; i < 20; i++) { + mockMvc + .perform( + delete(Path.AUTH + "/tokens") + .header("X-Forwarded-For", "10.0.0.5") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + } + } +} diff --git a/backend/spring-boot/src/test/resources/application.yml b/backend/spring-boot/src/test/resources/application.yml index 639a44d8..f6ae01dc 100644 --- a/backend/spring-boot/src/test/resources/application.yml +++ b/backend/spring-boot/src/test/resources/application.yml @@ -72,6 +72,9 @@ jwt: reset-password-token: duration: ${RESET_PASSWORD_TOKEN_DURATION:900} +rate-limit: + enabled: false + springdoc: swagger-ui: url: /openapi.yml diff --git a/docs/src/content/how-it-works/_meta.js b/docs/src/content/how-it-works/_meta.js index 0f73865f..852231e5 100644 --- a/docs/src/content/how-it-works/_meta.js +++ b/docs/src/content/how-it-works/_meta.js @@ -4,6 +4,7 @@ const config = { auth: 'Auth', 'error-handling': 'Error Handling', i18n: 'Internationalization', + 'rate-limiting': 'Rate Limiting', email: 'Email', testing: 'Testing', }; diff --git a/docs/src/content/how-it-works/rate-limiting.mdx b/docs/src/content/how-it-works/rate-limiting.mdx new file mode 100644 index 00000000..463c0b35 --- /dev/null +++ b/docs/src/content/how-it-works/rate-limiting.mdx @@ -0,0 +1,52 @@ +## Rate Limiting + +The backend uses IP-based rate limiting to protect sensitive endpoints from brute force attacks and abuse. It is built with [Bucket4j](https://github.com/bucket4j/bucket4j) (token bucket algorithm) and [Caffeine](https://github.com/ben-manes/caffeine) cache for in-memory bucket storage. + +### How It Works + +A custom `@RateLimit` annotation is applied to controller methods that need throttling: + +```java +@RateLimit(requests = 10, duration = 60) +@PostMapping("/authenticate") +public ResponseEntity authenticate(...) { ... } +``` + +- `requests` — maximum number of requests allowed in the window. +- `duration` — window size in seconds. + +When a request comes in, the `RateLimitInterceptor` checks if the handler method has a `@RateLimit` annotation. If it does, a token bucket is resolved for the client's IP and endpoint combination. If the bucket is exhausted, a `429 Too Many Requests` response is returned with a `Retry-After` header. + +### Client IP Resolution + +The client IP is resolved from the `X-Forwarded-For` header (for deployments behind a reverse proxy), falling back to `request.getRemoteAddr()` for direct connections. + +### Rate-Limited Endpoints + +| Endpoint | Limit | +|---|---| +| `POST /auth/authenticate` | 10 req / 60s | +| `POST /auth/register` | 5 req / 60s | +| `POST /auth/tokens/refresh` | 10 req / 60s | +| `POST /auth/password/forgot` | 3 req / 60s | +| `POST /auth/password/reset` | 5 req / 60s | +| `POST /auth/verification-email` | 3 req / 60s | +| `POST /auth/verify-email` | 5 req / 60s | +| `POST /users/username/availability` | 10 req / 60s | +| `POST /users/email/availability` | 10 req / 60s | +| `PATCH /profile/password` | 5 req / 60s | + +### Bucket Isolation + +Each rate limit bucket is scoped to a **specific endpoint + client IP** combination. This means: + +- Different endpoints have independent limits — exhausting the login limit does not affect forgot-password. +- Different IPs have independent limits — one client being throttled does not affect others. + +### Configuration + +Rate limiting is enabled by default. It can be disabled by setting `rate-limit.enabled` to `false` in `application.yml`. This is used in the test profile to prevent interference with integration tests. + +### Storage + +Buckets are stored in-memory using Caffeine caches with automatic eviction after the rate limit window plus one minute of inactivity. This is suitable for single-instance deployments. For multi-instance deployments, the bucket storage can be swapped to a distributed backend (e.g., Redis) without changing the annotation-based API. From 88ba1d721dd5327495e68fae713442e47013aa3f Mon Sep 17 00:00:00 2001 From: Dejan Zdravkovic Date: Sun, 22 Feb 2026 10:53:47 +0100 Subject: [PATCH 02/10] rate limiter with redis --- backend/spring-boot/pom.xml | 11 ++--- .../api/shared/config/RedisConfig.java | 41 +++++++++++------ .../ratelimit/RateLimitInterceptor.java | 46 ++++++++++++------- .../bugzkit/api/user/payload/dto/UserDTO.java | 4 +- .../api/shared/config/DatabaseContainers.java | 18 ++++++-- 5 files changed, 74 insertions(+), 46 deletions(-) diff --git a/backend/spring-boot/pom.xml b/backend/spring-boot/pom.xml index 3702702a..9d18e989 100644 --- a/backend/spring-boot/pom.xml +++ b/backend/spring-boot/pom.xml @@ -22,7 +22,6 @@ 33.5.0-jre 4.5.0 1.6.3 - 7.2.1 3.0.1 2.5.4 8.16.1 @@ -97,19 +96,15 @@ mapstruct ${mapstruct.version} - - redis.clients - jedis - ${jedis.version} - com.bucket4j bucket4j_jdk17-core ${bucket4j.version} - com.github.ben-manes.caffeine - caffeine + com.bucket4j + bucket4j_jdk17-lettuce + ${bucket4j.version} org.springdoc diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/RedisConfig.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/RedisConfig.java index 8a2ebda8..d09afbca 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/RedisConfig.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/RedisConfig.java @@ -1,5 +1,7 @@ package org.bugzkit.api.shared.config; +import io.lettuce.core.RedisClient; +import io.lettuce.core.RedisURI; import java.time.Duration; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -7,14 +9,14 @@ import org.springframework.context.annotation.Profile; import org.springframework.data.redis.connection.RedisPassword; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; -import org.springframework.data.redis.connection.jedis.JedisClientConfiguration; -import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisKeyValueAdapter.EnableKeyspaceEvents; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; @Configuration -@Profile({"dev", "prod"}) +@Profile({"dev", "prod", "test"}) @EnableRedisRepositories(enableKeyspaceEvents = EnableKeyspaceEvents.ON_STARTUP) public class RedisConfig { @Value("${spring.data.redis.host}") @@ -33,21 +35,34 @@ public class RedisConfig { private int timeout; @Bean - JedisConnectionFactory jedisConnectionFactory() { - final var redisStandaloneConfiguration = new RedisStandaloneConfiguration(); - redisStandaloneConfiguration.setHostName(host); - redisStandaloneConfiguration.setPort(port); - redisStandaloneConfiguration.setDatabase(database); - redisStandaloneConfiguration.setPassword(RedisPassword.of(password)); - return new JedisConnectionFactory( - redisStandaloneConfiguration, - JedisClientConfiguration.builder().connectTimeout(Duration.ofSeconds(timeout)).build()); + LettuceConnectionFactory redisConnectionFactory() { + final var redisConfig = new RedisStandaloneConfiguration(); + redisConfig.setHostName(host); + redisConfig.setPort(port); + redisConfig.setDatabase(database); + redisConfig.setPassword(RedisPassword.of(password)); + final var clientConfig = + LettuceClientConfiguration.builder().commandTimeout(Duration.ofSeconds(timeout)).build(); + return new LettuceConnectionFactory(redisConfig, clientConfig); } @Bean public RedisTemplate redisTemplate() { final var template = new RedisTemplate(); - template.setConnectionFactory(jedisConnectionFactory()); + template.setConnectionFactory(redisConnectionFactory()); return template; } + + @Bean(destroyMethod = "shutdown") + public RedisClient lettuceClient() { + final var redisURI = + RedisURI.builder() + .withHost(host) + .withPort(port) + .withDatabase(database) + .withPassword(password.toCharArray()) + .withTimeout(Duration.ofSeconds(timeout)) + .build(); + return RedisClient.create(redisURI); + } } diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java index 867d89c4..c4d98e93 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java @@ -1,10 +1,15 @@ package org.bugzkit.api.shared.ratelimit; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; import io.github.bucket4j.Bandwidth; -import io.github.bucket4j.Bucket; +import io.github.bucket4j.BucketConfiguration; import io.github.bucket4j.ConsumptionProbe; +import io.github.bucket4j.distributed.ExpirationAfterWriteStrategy; +import io.github.bucket4j.redis.lettuce.Bucket4jLettuce; +import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager; +import io.lettuce.core.RedisClient; +import io.lettuce.core.codec.ByteArrayCodec; +import io.lettuce.core.codec.RedisCodec; +import io.lettuce.core.codec.StringCodec; import jakarta.annotation.Nonnull; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -25,12 +30,23 @@ public class RateLimitInterceptor implements HandlerInterceptor { private final MessageService messageService; private final boolean enabled; - private final Map> caches = new ConcurrentHashMap<>(); + private final LettuceBasedProxyManager proxyManager; + private final Map configCache = new ConcurrentHashMap<>(); public RateLimitInterceptor( - MessageService messageService, @Value("${rate-limit.enabled:true}") boolean enabled) { + MessageService messageService, + @Value("${rate-limit.enabled:true}") boolean enabled, + RedisClient lettuceClient) { this.messageService = messageService; this.enabled = enabled; + final var connection = + lettuceClient.connect(RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE)); + this.proxyManager = + Bucket4jLettuce.casBasedBuilder(connection) + .expirationAfterWrite( + ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax( + Duration.ofSeconds(60))) + .build(); } @Override @@ -44,17 +60,13 @@ public boolean preHandle( final var rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class); if (rateLimit == null) return true; - final var key = resolveClientIp(request); - final var cacheKey = + final var ip = resolveClientIp(request); + final var endpointKey = handlerMethod.getBeanType().getSimpleName() + ":" + handlerMethod.getMethod().getName(); - final var cache = - caches.computeIfAbsent( - cacheKey, - k -> - Caffeine.newBuilder() - .expireAfterAccess(rateLimit.duration() + 60, TimeUnit.SECONDS) - .build()); - final var bucket = cache.get(key, k -> createBucket(rateLimit)); + final var bucketKey = "rate-limit:" + endpointKey + ":" + ip; + final var config = + configCache.computeIfAbsent(endpointKey, k -> buildBucketConfiguration(rateLimit)); + final var bucket = proxyManager.getProxy(bucketKey, () -> config); final var probe = bucket.tryConsumeAndReturnRemaining(1); if (probe.isConsumed()) return true; @@ -63,13 +75,13 @@ public boolean preHandle( return false; } - private Bucket createBucket(RateLimit rateLimit) { + private BucketConfiguration buildBucketConfiguration(RateLimit rateLimit) { final var bandwidth = Bandwidth.builder() .capacity(rateLimit.requests()) .refillGreedy(rateLimit.requests(), Duration.ofSeconds(rateLimit.duration())) .build(); - return Bucket.builder().addLimit(bandwidth).build(); + return BucketConfiguration.builder().addLimit(bandwidth).build(); } private String resolveClientIp(HttpServletRequest request) { diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/user/payload/dto/UserDTO.java b/backend/spring-boot/src/main/java/org/bugzkit/api/user/payload/dto/UserDTO.java index 03b27c93..e1990896 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/user/payload/dto/UserDTO.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/user/payload/dto/UserDTO.java @@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; import com.google.common.base.Objects; -import com.google.gson.annotations.SerializedName; import java.time.LocalDateTime; import java.util.Set; import lombok.Builder; @@ -17,8 +16,7 @@ public record UserDTO( @JsonInclude(Include.NON_NULL) Boolean active, @JsonInclude(Include.NON_NULL) Boolean lock, LocalDateTime createdAt, - @JsonInclude(Include.NON_NULL) @JsonProperty("roles") @SerializedName("roles") - Set roleDTOs) { + @JsonInclude(Include.NON_NULL) @JsonProperty("roles") Set roleDTOs) { @Override public boolean equals(Object o) { diff --git a/backend/spring-boot/src/test/java/org/bugzkit/api/shared/config/DatabaseContainers.java b/backend/spring-boot/src/test/java/org/bugzkit/api/shared/config/DatabaseContainers.java index 480a46dd..97ab1a9c 100644 --- a/backend/spring-boot/src/test/java/org/bugzkit/api/shared/config/DatabaseContainers.java +++ b/backend/spring-boot/src/test/java/org/bugzkit/api/shared/config/DatabaseContainers.java @@ -1,21 +1,29 @@ package org.bugzkit.api.shared.config; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; import org.testcontainers.utility.DockerImageName; @Testcontainers public abstract class DatabaseContainers { @Container @ServiceConnection - static PostgreSQLContainer postgres = - new PostgreSQLContainer<>(DockerImageName.parse("postgres").withTag("latest")); + static PostgreSQLContainer postgres = + new PostgreSQLContainer(DockerImageName.parse("postgres").withTag("latest")); @Container - @ServiceConnection(name = "redis") static GenericContainer redis = new GenericContainer<>(DockerImageName.parse("redis").withTag("latest")) - .withExposedPorts(6379); + .withExposedPorts(6379) + .withCommand("redis-server", "--requirepass", "root"); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", redis::getHost); + registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379)); + } } From 544c97f5a420199070d4919207e631a69ac8abad Mon Sep 17 00:00:00 2001 From: Dejan Zdravkovic Date: Sun, 22 Feb 2026 11:03:55 +0100 Subject: [PATCH 03/10] dynamic maxDuration --- .../ratelimit/RateLimitInterceptor.java | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java index c4d98e93..b45697aa 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java @@ -14,41 +14,67 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.time.Duration; +import java.util.Arrays; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import org.bugzkit.api.shared.error.ErrorMessage; import org.bugzkit.api.shared.message.service.MessageService; +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; +import org.springframework.stereotype.Controller; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; @Component -public class RateLimitInterceptor implements HandlerInterceptor { +public class RateLimitInterceptor implements HandlerInterceptor, SmartInitializingSingleton { private final MessageService messageService; private final boolean enabled; - private final LettuceBasedProxyManager proxyManager; + private final RedisClient lettuceClient; + private final ApplicationContext applicationContext; private final Map configCache = new ConcurrentHashMap<>(); + private LettuceBasedProxyManager proxyManager; public RateLimitInterceptor( MessageService messageService, @Value("${rate-limit.enabled:true}") boolean enabled, - RedisClient lettuceClient) { + RedisClient lettuceClient, + ApplicationContext applicationContext) { this.messageService = messageService; this.enabled = enabled; + this.lettuceClient = lettuceClient; + this.applicationContext = applicationContext; + } + + @Override + public void afterSingletonsInstantiated() { final var connection = lettuceClient.connect(RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE)); + final var maxDuration = resolveMaxRateLimitDuration(); this.proxyManager = Bucket4jLettuce.casBasedBuilder(connection) .expirationAfterWrite( ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax( - Duration.ofSeconds(60))) + Duration.ofSeconds(maxDuration))) .build(); } + private long resolveMaxRateLimitDuration() { + return applicationContext.getBeansWithAnnotation(Controller.class).values().stream() + .flatMap(bean -> Arrays.stream(AopUtils.getTargetClass(bean).getMethods())) + .map(method -> method.getAnnotation(RateLimit.class)) + .filter(Objects::nonNull) + .mapToLong(RateLimit::duration) + .max() + .orElse(60L); + } + @Override public boolean preHandle( @Nonnull HttpServletRequest request, From 11a078f19ab9d963eeb7fe54f6b54d6cc6a8169e Mon Sep 17 00:00:00 2001 From: Dejan Zdravkovic Date: Sun, 22 Feb 2026 11:10:41 +0100 Subject: [PATCH 04/10] rate-limit.enabled in config --- .../org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java | 2 +- backend/spring-boot/src/main/resources/application.yml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java index b45697aa..6f26ee35 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java @@ -43,7 +43,7 @@ public class RateLimitInterceptor implements HandlerInterceptor, SmartInitializi public RateLimitInterceptor( MessageService messageService, - @Value("${rate-limit.enabled:true}") boolean enabled, + @Value("${rate-limit.enabled}") boolean enabled, RedisClient lettuceClient, ApplicationContext applicationContext) { this.messageService = messageService; diff --git a/backend/spring-boot/src/main/resources/application.yml b/backend/spring-boot/src/main/resources/application.yml index 1b469c53..7389001b 100644 --- a/backend/spring-boot/src/main/resources/application.yml +++ b/backend/spring-boot/src/main/resources/application.yml @@ -50,6 +50,9 @@ jwt: reset-password-token: duration: ${RESET_PASSWORD_TOKEN_DURATION:900} +rate-limit: + enabled: true + springdoc: swagger-ui: url: /openapi.yml From 8cd2e943df266909c3f59aa6a1e0f94bc628a40c Mon Sep 17 00:00:00 2001 From: Dejan Zdravkovic Date: Sun, 22 Feb 2026 12:03:05 +0100 Subject: [PATCH 05/10] resolve ip address and docs --- .../ratelimit/RateLimitInterceptor.java | 14 ++++------ .../org/bugzkit/api/shared/util/Utils.java | 17 +++++++++++ .../src/main/resources/application-prod.yml | 1 + docs/src/content/deploy.mdx | 19 +++++++++++++ .../content/how-it-works/rate-limiting.mdx | 28 +++++++++---------- 5 files changed, 56 insertions(+), 23 deletions(-) create mode 100644 backend/spring-boot/src/main/java/org/bugzkit/api/shared/util/Utils.java diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java index 6f26ee35..0b00cbdb 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java @@ -21,6 +21,7 @@ import java.util.concurrent.TimeUnit; import org.bugzkit.api.shared.error.ErrorMessage; import org.bugzkit.api.shared.message.service.MessageService; +import org.bugzkit.api.shared.util.Utils; import org.springframework.aop.support.AopUtils; import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Value; @@ -36,6 +37,7 @@ public class RateLimitInterceptor implements HandlerInterceptor, SmartInitializingSingleton { private final MessageService messageService; private final boolean enabled; + private final String clientIpHeader; private final RedisClient lettuceClient; private final ApplicationContext applicationContext; private final Map configCache = new ConcurrentHashMap<>(); @@ -44,10 +46,12 @@ public class RateLimitInterceptor implements HandlerInterceptor, SmartInitializi public RateLimitInterceptor( MessageService messageService, @Value("${rate-limit.enabled}") boolean enabled, + @Value("${server.client-ip-header:}") String clientIpHeader, RedisClient lettuceClient, ApplicationContext applicationContext) { this.messageService = messageService; this.enabled = enabled; + this.clientIpHeader = clientIpHeader; this.lettuceClient = lettuceClient; this.applicationContext = applicationContext; } @@ -86,7 +90,7 @@ public boolean preHandle( final var rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class); if (rateLimit == null) return true; - final var ip = resolveClientIp(request); + final var ip = Utils.resolveClientIp(request, clientIpHeader); final var endpointKey = handlerMethod.getBeanType().getSimpleName() + ":" + handlerMethod.getMethod().getName(); final var bucketKey = "rate-limit:" + endpointKey + ":" + ip; @@ -110,14 +114,6 @@ private BucketConfiguration buildBucketConfiguration(RateLimit rateLimit) { return BucketConfiguration.builder().addLimit(bandwidth).build(); } - private String resolveClientIp(HttpServletRequest request) { - final var forwarded = request.getHeader("X-Forwarded-For"); - if (forwarded != null && !forwarded.isEmpty()) { - return forwarded.split(",")[0].trim(); - } - return request.getRemoteAddr(); - } - private void writeRateLimitResponse(HttpServletResponse response, ConsumptionProbe probe) throws Exception { final var retryAfter = TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill()); diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/util/Utils.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/util/Utils.java new file mode 100644 index 00000000..e099f4a5 --- /dev/null +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/util/Utils.java @@ -0,0 +1,17 @@ +package org.bugzkit.api.shared.util; + +import jakarta.servlet.http.HttpServletRequest; + +public final class Utils { + private Utils() {} + + public static String resolveClientIp(HttpServletRequest request, String clientIpHeader) { + if (clientIpHeader != null && !clientIpHeader.isBlank()) { + final var ip = request.getHeader(clientIpHeader); + if (ip != null && !ip.isBlank()) { + return ip; + } + } + return request.getRemoteAddr(); + } +} diff --git a/backend/spring-boot/src/main/resources/application-prod.yml b/backend/spring-boot/src/main/resources/application-prod.yml index 59ee801f..042e9735 100644 --- a/backend/spring-boot/src/main/resources/application-prod.yml +++ b/backend/spring-boot/src/main/resources/application-prod.yml @@ -6,6 +6,7 @@ domain: server: forward-headers-strategy: native + client-ip-header: ${CLIENT_IP_HEADER:CF-Connecting-IP} spring: config: diff --git a/docs/src/content/deploy.mdx b/docs/src/content/deploy.mdx index 2ff68337..9e5ec348 100644 --- a/docs/src/content/deploy.mdx +++ b/docs/src/content/deploy.mdx @@ -54,3 +54,22 @@ To deploy manually on a Docker Swarm node: ```bash docker stack deploy -c docker-stack.prod.yml bugzkit ``` + +### Client IP Resolution + +The production profile is configured for a Cloudflare + Traefik setup out of the box: + +- `server.forward-headers-strategy: native` — Tomcat resolves the real client IP from `X-Forwarded-For` set by Traefik. +- `server.client-ip-header: CF-Connecting-IP` — the API reads Cloudflare's header for the real visitor IP, which cannot be spoofed by clients. + +If you use a different CDN or no CDN at all, override the `CLIENT_IP_HEADER` environment variable: + +| Setup | `CLIENT_IP_HEADER` | +| ------------------------ | ------------------ | +| Cloudflare (default) | `CF-Connecting-IP` | +| Fastly | `Fastly-Client-IP` | +| Akamai | `True-Client-IP` | +| nginx (`real_ip` module) | `X-Real-IP` | +| Traefik only (no CDN) | _(unset)_ | + +When unset, the server falls back to `getRemoteAddr()` resolved by Tomcat from `X-Forwarded-For`, which is correct for a Traefik-only deployment. diff --git a/docs/src/content/how-it-works/rate-limiting.mdx b/docs/src/content/how-it-works/rate-limiting.mdx index 463c0b35..26e1373a 100644 --- a/docs/src/content/how-it-works/rate-limiting.mdx +++ b/docs/src/content/how-it-works/rate-limiting.mdx @@ -1,6 +1,6 @@ ## Rate Limiting -The backend uses IP-based rate limiting to protect sensitive endpoints from brute force attacks and abuse. It is built with [Bucket4j](https://github.com/bucket4j/bucket4j) (token bucket algorithm) and [Caffeine](https://github.com/ben-manes/caffeine) cache for in-memory bucket storage. +The backend uses IP-based rate limiting to protect sensitive endpoints from brute force attacks and abuse. It is built with [Bucket4j](https://github.com/bucket4j/bucket4j) (token bucket algorithm) and Redis for distributed bucket storage. ### How It Works @@ -19,22 +19,22 @@ When a request comes in, the `RateLimitInterceptor` checks if the handler method ### Client IP Resolution -The client IP is resolved from the `X-Forwarded-For` header (for deployments behind a reverse proxy), falling back to `request.getRemoteAddr()` for direct connections. +The client IP is resolved via `HttpRequestUtil.resolveClientIp()`. It checks the configured `server.client-ip-header` first (a CDN-specific header such as `CF-Connecting-IP` for Cloudflare), falling back to `request.getRemoteAddr()`. In production, Tomcat's `RemoteIpValve` (`server.forward-headers-strategy: native`) ensures `getRemoteAddr()` reflects the real client IP from `X-Forwarded-For` set by Traefik. See the [Deploy](/deploy) page for configuration details. ### Rate-Limited Endpoints -| Endpoint | Limit | -|---|---| -| `POST /auth/authenticate` | 10 req / 60s | -| `POST /auth/register` | 5 req / 60s | -| `POST /auth/tokens/refresh` | 10 req / 60s | -| `POST /auth/password/forgot` | 3 req / 60s | -| `POST /auth/password/reset` | 5 req / 60s | -| `POST /auth/verification-email` | 3 req / 60s | -| `POST /auth/verify-email` | 5 req / 60s | +| Endpoint | Limit | +| ----------------------------------- | ------------ | +| `POST /auth/authenticate` | 10 req / 60s | +| `POST /auth/register` | 5 req / 60s | +| `POST /auth/tokens/refresh` | 10 req / 60s | +| `POST /auth/password/forgot` | 3 req / 60s | +| `POST /auth/password/reset` | 5 req / 60s | +| `POST /auth/verification-email` | 3 req / 60s | +| `POST /auth/verify-email` | 5 req / 60s | | `POST /users/username/availability` | 10 req / 60s | -| `POST /users/email/availability` | 10 req / 60s | -| `PATCH /profile/password` | 5 req / 60s | +| `POST /users/email/availability` | 10 req / 60s | +| `PATCH /profile/password` | 5 req / 60s | ### Bucket Isolation @@ -49,4 +49,4 @@ Rate limiting is enabled by default. It can be disabled by setting `rate-limit.e ### Storage -Buckets are stored in-memory using Caffeine caches with automatic eviction after the rate limit window plus one minute of inactivity. This is suitable for single-instance deployments. For multi-instance deployments, the bucket storage can be swapped to a distributed backend (e.g., Redis) without changing the annotation-based API. +Buckets are stored in Redis via Bucket4j's Lettuce integration. Redis keys are namespaced as `rate-limit:::` and expire automatically using a `basedOnTimeForRefillingBucketUpToMax` strategy — keys live only as long as the longest configured rate limit window. Since buckets are stored in the same Redis instance used for token storage, no additional infrastructure is required. This also makes rate limiting work correctly across multiple API instances. From 888995508f1cd685e8f7d414afaabb160399774c Mon Sep 17 00:00:00 2001 From: Dejan Zdravkovic Date: Tue, 24 Feb 2026 09:07:18 +0100 Subject: [PATCH 06/10] fix RateLimitIT --- .../api/shared/integration/RateLimitIT.java | 20 ++++++++++--------- .../src/test/resources/application.yml | 1 + 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/RateLimitIT.java b/backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/RateLimitIT.java index ca6e8161..5243f786 100644 --- a/backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/RateLimitIT.java +++ b/backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/RateLimitIT.java @@ -14,6 +14,7 @@ import org.bugzkit.api.shared.email.service.EmailService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.http.MediaType; @@ -33,6 +34,7 @@ class RateLimitIT extends DatabaseContainers { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @MockitoBean private EmailService emailService; + @Value("${server.client-ip-header}") private String clientIpHeader; @Test void forgotPassword_withinLimitSucceeds_exceedLimitReturns429() throws Exception { @@ -43,7 +45,7 @@ void forgotPassword_withinLimitSucceeds_exceedLimitReturns429() throws Exception mockMvc .perform( post(Path.AUTH + "/password/forgot") - .header("X-Forwarded-For", "10.0.0.1") + .header(clientIpHeader,"10.0.0.1") .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isNoContent()); @@ -53,7 +55,7 @@ void forgotPassword_withinLimitSucceeds_exceedLimitReturns429() throws Exception mockMvc .perform( post(Path.AUTH + "/password/forgot") - .header("X-Forwarded-For", "10.0.0.1") + .header(clientIpHeader,"10.0.0.1") .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isTooManyRequests()) @@ -73,7 +75,7 @@ void rateLimitedEndpoints_haveIndependentBuckets() throws Exception { mockMvc .perform( post(Path.AUTH + "/password/forgot") - .header("X-Forwarded-For", "10.0.0.2") + .header(clientIpHeader,"10.0.0.2") .contentType(MediaType.APPLICATION_JSON) .content(forgotBody)) .andExpect(status().isNoContent()); @@ -83,7 +85,7 @@ void rateLimitedEndpoints_haveIndependentBuckets() throws Exception { mockMvc .perform( post(Path.AUTH + "/password/forgot") - .header("X-Forwarded-For", "10.0.0.2") + .header(clientIpHeader,"10.0.0.2") .contentType(MediaType.APPLICATION_JSON) .content(forgotBody)) .andExpect(status().isTooManyRequests()); @@ -92,7 +94,7 @@ void rateLimitedEndpoints_haveIndependentBuckets() throws Exception { mockMvc .perform( post(Path.AUTH + "/verification-email") - .header("X-Forwarded-For", "10.0.0.2") + .header(clientIpHeader,"10.0.0.2") .contentType(MediaType.APPLICATION_JSON) .content(verificationBody)) .andExpect(status().isNoContent()); @@ -107,7 +109,7 @@ void rateLimitIsPerIp_differentIpsHaveIndependentBuckets() throws Exception { mockMvc .perform( post(Path.AUTH + "/password/forgot") - .header("X-Forwarded-For", "10.0.0.3") + .header(clientIpHeader,"10.0.0.3") .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isNoContent()); @@ -117,7 +119,7 @@ void rateLimitIsPerIp_differentIpsHaveIndependentBuckets() throws Exception { mockMvc .perform( post(Path.AUTH + "/password/forgot") - .header("X-Forwarded-For", "10.0.0.3") + .header(clientIpHeader,"10.0.0.3") .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isTooManyRequests()); @@ -126,7 +128,7 @@ void rateLimitIsPerIp_differentIpsHaveIndependentBuckets() throws Exception { mockMvc .perform( post(Path.AUTH + "/password/forgot") - .header("X-Forwarded-For", "10.0.0.4") + .header(clientIpHeader,"10.0.0.4") .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isNoContent()); @@ -139,7 +141,7 @@ void endpointWithoutRateLimit_neverThrottled() throws Exception { mockMvc .perform( delete(Path.AUTH + "/tokens") - .header("X-Forwarded-For", "10.0.0.5") + .header(clientIpHeader,"10.0.0.5") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isNoContent()); } diff --git a/backend/spring-boot/src/test/resources/application.yml b/backend/spring-boot/src/test/resources/application.yml index f6ae01dc..de7a73f1 100644 --- a/backend/spring-boot/src/test/resources/application.yml +++ b/backend/spring-boot/src/test/resources/application.yml @@ -2,6 +2,7 @@ app: name: ${APP_NAME:bugzkit} server: port: ${API_PORT:8080} + client-ip-header: CF-Connecting-IP ui: url: ${UI_URL:http://localhost:5173} domain: From d4e291d18bd53ec38f5e85252c016e91758baf2f Mon Sep 17 00:00:00 2001 From: Dejan Zdravkovic Date: Tue, 24 Feb 2026 09:09:59 +0100 Subject: [PATCH 07/10] lint fix --- .../api/shared/integration/RateLimitIT.java | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/RateLimitIT.java b/backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/RateLimitIT.java index 5243f786..a3a10451 100644 --- a/backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/RateLimitIT.java +++ b/backend/spring-boot/src/test/java/org/bugzkit/api/shared/integration/RateLimitIT.java @@ -34,7 +34,9 @@ class RateLimitIT extends DatabaseContainers { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @MockitoBean private EmailService emailService; - @Value("${server.client-ip-header}") private String clientIpHeader; + + @Value("${server.client-ip-header}") + private String clientIpHeader; @Test void forgotPassword_withinLimitSucceeds_exceedLimitReturns429() throws Exception { @@ -45,7 +47,7 @@ void forgotPassword_withinLimitSucceeds_exceedLimitReturns429() throws Exception mockMvc .perform( post(Path.AUTH + "/password/forgot") - .header(clientIpHeader,"10.0.0.1") + .header(clientIpHeader, "10.0.0.1") .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isNoContent()); @@ -55,7 +57,7 @@ void forgotPassword_withinLimitSucceeds_exceedLimitReturns429() throws Exception mockMvc .perform( post(Path.AUTH + "/password/forgot") - .header(clientIpHeader,"10.0.0.1") + .header(clientIpHeader, "10.0.0.1") .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isTooManyRequests()) @@ -75,7 +77,7 @@ void rateLimitedEndpoints_haveIndependentBuckets() throws Exception { mockMvc .perform( post(Path.AUTH + "/password/forgot") - .header(clientIpHeader,"10.0.0.2") + .header(clientIpHeader, "10.0.0.2") .contentType(MediaType.APPLICATION_JSON) .content(forgotBody)) .andExpect(status().isNoContent()); @@ -85,7 +87,7 @@ void rateLimitedEndpoints_haveIndependentBuckets() throws Exception { mockMvc .perform( post(Path.AUTH + "/password/forgot") - .header(clientIpHeader,"10.0.0.2") + .header(clientIpHeader, "10.0.0.2") .contentType(MediaType.APPLICATION_JSON) .content(forgotBody)) .andExpect(status().isTooManyRequests()); @@ -94,7 +96,7 @@ void rateLimitedEndpoints_haveIndependentBuckets() throws Exception { mockMvc .perform( post(Path.AUTH + "/verification-email") - .header(clientIpHeader,"10.0.0.2") + .header(clientIpHeader, "10.0.0.2") .contentType(MediaType.APPLICATION_JSON) .content(verificationBody)) .andExpect(status().isNoContent()); @@ -109,7 +111,7 @@ void rateLimitIsPerIp_differentIpsHaveIndependentBuckets() throws Exception { mockMvc .perform( post(Path.AUTH + "/password/forgot") - .header(clientIpHeader,"10.0.0.3") + .header(clientIpHeader, "10.0.0.3") .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isNoContent()); @@ -119,7 +121,7 @@ void rateLimitIsPerIp_differentIpsHaveIndependentBuckets() throws Exception { mockMvc .perform( post(Path.AUTH + "/password/forgot") - .header(clientIpHeader,"10.0.0.3") + .header(clientIpHeader, "10.0.0.3") .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isTooManyRequests()); @@ -128,7 +130,7 @@ void rateLimitIsPerIp_differentIpsHaveIndependentBuckets() throws Exception { mockMvc .perform( post(Path.AUTH + "/password/forgot") - .header(clientIpHeader,"10.0.0.4") + .header(clientIpHeader, "10.0.0.4") .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isNoContent()); @@ -141,7 +143,7 @@ void endpointWithoutRateLimit_neverThrottled() throws Exception { mockMvc .perform( delete(Path.AUTH + "/tokens") - .header(clientIpHeader,"10.0.0.5") + .header(clientIpHeader, "10.0.0.5") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isNoContent()); } From bc82bc032460e1e40e9379220a7f4e7b8a40cc37 Mon Sep 17 00:00:00 2001 From: Dejan Zdravkovic Date: Tue, 24 Feb 2026 09:31:31 +0100 Subject: [PATCH 08/10] client ip header default value --- .../bugzkit/api/shared/config/SecurityConfig.java | 2 ++ .../api/shared/ratelimit/RateLimitInterceptor.java | 12 ++++++------ .../src/main/resources/application-dev.yml | 3 +++ .../spring-boot/src/test/resources/application.yml | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/SecurityConfig.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/SecurityConfig.java index 06d6a571..e3194e39 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/SecurityConfig.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/config/SecurityConfig.java @@ -90,6 +90,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers(OAUTH2_WHITELIST) .permitAll() + .requestMatchers("/favicon.ico") + .permitAll() .anyRequest() .authenticated()) .oauth2Login( diff --git a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java index 0b00cbdb..376cbf8d 100644 --- a/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java +++ b/backend/spring-boot/src/main/java/org/bugzkit/api/shared/ratelimit/RateLimitInterceptor.java @@ -36,22 +36,22 @@ @Component public class RateLimitInterceptor implements HandlerInterceptor, SmartInitializingSingleton { private final MessageService messageService; - private final boolean enabled; - private final String clientIpHeader; private final RedisClient lettuceClient; private final ApplicationContext applicationContext; private final Map configCache = new ConcurrentHashMap<>(); private LettuceBasedProxyManager proxyManager; + @Value("${rate-limit.enabled}") + private boolean enabled; + + @Value("${server.client-ip-header}") + private String clientIpHeader; + public RateLimitInterceptor( MessageService messageService, - @Value("${rate-limit.enabled}") boolean enabled, - @Value("${server.client-ip-header:}") String clientIpHeader, RedisClient lettuceClient, ApplicationContext applicationContext) { this.messageService = messageService; - this.enabled = enabled; - this.clientIpHeader = clientIpHeader; this.lettuceClient = lettuceClient; this.applicationContext = applicationContext; } diff --git a/backend/spring-boot/src/main/resources/application-dev.yml b/backend/spring-boot/src/main/resources/application-dev.yml index 24458e75..b11c1093 100644 --- a/backend/spring-boot/src/main/resources/application-dev.yml +++ b/backend/spring-boot/src/main/resources/application-dev.yml @@ -4,6 +4,9 @@ ui: domain: name: ${DOMAIN_NAME:localhost} +server: + client-ip-header: ${CLIENT_IP_HEADER:} + spring: datasource: url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DATABASE:bugzkit} diff --git a/backend/spring-boot/src/test/resources/application.yml b/backend/spring-boot/src/test/resources/application.yml index de7a73f1..251dced9 100644 --- a/backend/spring-boot/src/test/resources/application.yml +++ b/backend/spring-boot/src/test/resources/application.yml @@ -2,7 +2,7 @@ app: name: ${APP_NAME:bugzkit} server: port: ${API_PORT:8080} - client-ip-header: CF-Connecting-IP + client-ip-header: ${CLIENT_IP_HEADER:CF-Connecting-IP} ui: url: ${UI_URL:http://localhost:5173} domain: From f3c0daa822494b062d3f2c5f295f10c1b6021843 Mon Sep 17 00:00:00 2001 From: Dejan Zdravkovic Date: Tue, 24 Feb 2026 09:56:41 +0100 Subject: [PATCH 09/10] env var docs --- docs/src/content/environment-variables.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/src/content/environment-variables.mdx b/docs/src/content/environment-variables.mdx index 6dc079b3..ed126646 100644 --- a/docs/src/content/environment-variables.mdx +++ b/docs/src/content/environment-variables.mdx @@ -29,3 +29,4 @@ | VERIFY_EMAIL_TOKEN_DURATION | Duration of the verify email token in seconds | 900 (15 minutes) | | RESET_PASSWORD_TOKEN_DURATION | Duration of the reset password token in seconds | 900 (15 minutes) | | USER_PASSWORD | Password for auto-generated Spring Security users | qwerty123 | +| CLIENT_IP_HEADER | Header used to resolve real client IP (e.g. behind a proxy) | CF-Connecting-IP | From 95fdaf9ef73e6ef4a3368b8dbb546a97a2dcbd21 Mon Sep 17 00:00:00 2001 From: Dejan Zdravkovic Date: Tue, 24 Feb 2026 09:57:51 +0100 Subject: [PATCH 10/10] env var docs --- docs/src/content/environment-variables.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/content/environment-variables.mdx b/docs/src/content/environment-variables.mdx index ed126646..a6d04275 100644 --- a/docs/src/content/environment-variables.mdx +++ b/docs/src/content/environment-variables.mdx @@ -29,4 +29,4 @@ | VERIFY_EMAIL_TOKEN_DURATION | Duration of the verify email token in seconds | 900 (15 minutes) | | RESET_PASSWORD_TOKEN_DURATION | Duration of the reset password token in seconds | 900 (15 minutes) | | USER_PASSWORD | Password for auto-generated Spring Security users | qwerty123 | -| CLIENT_IP_HEADER | Header used to resolve real client IP (e.g. behind a proxy) | CF-Connecting-IP | +| CLIENT_IP_HEADER | Header used to resolve real client IP (e.g. behind a proxy) | CF-Connecting-IP |