Skip to content

[Detail Bug] Contact form rate limiting uses proxy IP behind reverse proxy, causing global throttling #169

Description

@detail-app

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions