Detail Bug Report
https://app.detail.dev/org_befd6425-a158-4e24-9d4d-1e5c08769515/bugs/bug_60fbcf17-d3f8-4cae-954e-03bc92f11805
Introduced in #161 by @WilliamAGH on Aug 1, 2026
Summary
- Context: The ContactController handles public contact form submissions and implements per-IP rate limiting to prevent abuse.
- Bug: The IP address extraction uses
servletRequest.getRemoteAddr(), which returns the reverse proxy's IP instead of the real client IP.
- Actual vs. expected: Behind a reverse proxy, all users share the same IP (the proxy's), so the per-IP rate limit applies globally instead of per-client.
- Impact: The rate limiting feature does not function as designed. Legitimate users can be rate-limited after 3 total submissions across all users, and coordinated abuse is not prevented.
Code with Bug
@PostMapping
public ResponseEntity<ApiResponse> submitContactMessage(
@Valid @RequestBody ContactMessageRequest contactMessageRequest, HttpServletRequest servletRequest) {
try {
ContactSubmission contactSubmission = new ContactSubmission(
contactMessageRequest.name(),
contactMessageRequest.email(),
contactMessageRequest.message(),
contactMessageRequest.website(),
contactMessageRequest.renderedAt(),
servletRequest.getRemoteAddr(), // <-- BUG 🔴 Returns proxy IP, not client IP
Instant.now());
contactSubmissionUseCase.submit(contactSubmission);
return ResponseEntity.status(HttpStatus.ACCEPTED).body(ContactMessageAcknowledgement.accepted());
} catch (ContactRateLimitExceededException rateLimitExceededException) {
return exceptionBuilder.buildErrorResponse(
HttpStatus.TOO_MANY_REQUESTS, rateLimitExceededException.getMessage());
}
// ...
}
AtomicInteger acceptedSubmissionCount =
acceptedSubmissionsPerIp.get(contactSubmission.remoteAddress(), remoteAddress -> new AtomicInteger()); // <-- BUG 🔴 Keyed by (proxy) IP when behind reverse proxy
int reservedSubmissionSlot = acceptedSubmissionCount.incrementAndGet();
if (reservedSubmissionSlot > MAX_ACCEPTED_SUBMISSIONS_PER_IP) {
acceptedSubmissionCount.decrementAndGet();
log.info("Contact submission rate limited");
throw new ContactRateLimitExceededException();
}
Explanation
- The application is configured for reverse proxy deployment (e.g.,
server.forward-headers-strategy=framework), but HttpServletRequest.getRemoteAddr() still returns the TCP peer address (the proxy) unless Tomcat’s RemoteIpValve is enabled.
- As a result, the per-IP rate limiter buckets all requests under the proxy’s IP, effectively turning per-client throttling into global throttling.
- Tests don’t catch this because they set
remoteAddr directly in MockMvc, which doesn’t reflect real reverse-proxy behavior.
- The IP is also included in the email diagnostics body, so it will always show the proxy IP rather than the user’s.
Codebase Inconsistency
ContactSubmission Javadoc claims the stored IP is “proxy-correct via forward headers”, but the codebase has no RemoteIpValve configuration and no custom parsing of X-Forwarded-For, so this documented behavior is not actually implemented.
Recommended Fix
Add to application.properties:
server.tomcat.remote-ip-header=X-Forwarded-For
server.tomcat.protocol-header=X-Forwarded-Proto
This enables Tomcat's RemoteIpValve, which modifies getRemoteAddr() to return the X-Forwarded-For value.
Important: This requires the reverse proxy to overwrite (not just append to) the X-Forwarded-For header to prevent spoofing. Coolify's default nginx/Traefik configuration should handle this, but verify in production.
History
This bug was introduced in commit e34b631. The original feature commit added the contact form with "per-IP fixed-window rate limit" (explicitly stated in both the commit message and implementation), and even documented the IP parameter as "proxy-correct via forward headers" in the Javadoc. However, the implementation used servletRequest.getRemoteAddr() which only returns the client IP when Tomcat's RemoteIpValve is configured—a configuration that was never added, causing the feature to use proxy IPs instead of client IPs when deployed behind a reverse proxy.
Detail Bug Report
https://app.detail.dev/org_befd6425-a158-4e24-9d4d-1e5c08769515/bugs/bug_60fbcf17-d3f8-4cae-954e-03bc92f11805
Introduced in #161 by @WilliamAGH on Aug 1, 2026
Summary
servletRequest.getRemoteAddr(), which returns the reverse proxy's IP instead of the real client IP.Code with Bug
Explanation
server.forward-headers-strategy=framework), butHttpServletRequest.getRemoteAddr()still returns the TCP peer address (the proxy) unless Tomcat’sRemoteIpValveis enabled.remoteAddrdirectly in MockMvc, which doesn’t reflect real reverse-proxy behavior.Codebase Inconsistency
ContactSubmissionJavadoc claims the stored IP is “proxy-correct via forward headers”, but the codebase has noRemoteIpValveconfiguration and no custom parsing ofX-Forwarded-For, so this documented behavior is not actually implemented.Recommended Fix
Add to
application.properties:This enables Tomcat's
RemoteIpValve, which modifiesgetRemoteAddr()to return theX-Forwarded-Forvalue.Important: This requires the reverse proxy to overwrite (not just append to) the
X-Forwarded-Forheader to prevent spoofing. Coolify's default nginx/Traefik configuration should handle this, but verify in production.History
This bug was introduced in commit e34b631. The original feature commit added the contact form with "per-IP fixed-window rate limit" (explicitly stated in both the commit message and implementation), and even documented the IP parameter as "proxy-correct via forward headers" in the Javadoc. However, the implementation used
servletRequest.getRemoteAddr()which only returns the client IP when Tomcat's RemoteIpValve is configured—a configuration that was never added, causing the feature to use proxy IPs instead of client IPs when deployed behind a reverse proxy.