Skip to content

[Detail Bug] Security: Contact form rate limiting can be bypassed by spoofing X-Forwarded-For #171

Description

@detail-app

Detail Bug Report

https://app.detail.dev/org_befd6425-a158-4e24-9d4d-1e5c08769515/bugs/bug_d9dbf8cc-ab48-4e5d-92e0-40f382ee49d6

Introduced in #161 by @WilliamAGH on Aug 1, 2026

Summary

  • Context: The contact form rate limiting uses the client IP address (obtained via HttpServletRequest.getRemoteAddr()) as the key for a Caffeine cache that limits submissions to 3 per hour per IP.
  • Bug: An attacker can bypass the rate limit by sending arbitrary X-Forwarded-For headers, making each request appear to come from a different IP address.
  • Actual vs. expected: An attacker can send unlimited contact form submissions, when only 3 per hour per real IP should be allowed.
  • Impact: The rate limiting mechanism is completely ineffective, allowing unlimited spam/abuse of the contact form.

Code with Bug

// ContactSubmissionUseCase.java - rate limiting uses remoteAddress as cache key
AtomicInteger acceptedSubmissionCount =
        acceptedSubmissionsPerIp.get(contactSubmission.remoteAddress(), remoteAddress -> new AtomicInteger()); // <-- BUG 🔴 key is spoofable if remoteAddress comes from forwarded header
int reservedSubmissionSlot = acceptedSubmissionCount.incrementAndGet();
if (reservedSubmissionSlot > MAX_ACCEPTED_SUBMISSIONS_PER_IP) {
    acceptedSubmissionCount.decrementAndGet();
    throw new ContactRateLimitExceededException();
}
# application.properties - enables forwarded header processing without trusted proxy configuration
server.forward-headers-strategy=framework  # <-- BUG 🔴 trusts client-supplied X-Forwarded-For unless proxies are restricted
// ContactController.java - extracts IP directly from servlet request without validation
servletRequest.getRemoteAddr(), // <-- BUG 🔴 value can be influenced by X-Forwarded-For when ForwardedHeaderFilter is enabled

Explanation

Spring’s ForwardedHeaderFilter is enabled via server.forward-headers-strategy=framework, which causes HttpServletRequest.getRemoteAddr() to be derived from X-Forwarded-For. Because there is no trusted-proxy restriction (e.g., server.tomcat.internal-proxies), any direct client can supply arbitrary X-Forwarded-For values.

Since the rate limiter uses the request “remote address” as its cache key, an attacker can rotate spoofed IPs per request and never hit the per-IP limit.

Evidence: an integration test (ContactControllerIpSpoofingTest) loads the full app context (including forwarded header processing), fetches a real CSRF token from /api/security/csrf, then submits 10 contact requests each with a different X-Forwarded-For; all 10 are accepted and 10 emails are sent.

Exploit Scenario

  1. Attacker requests GET /api/security/csrf to obtain a CSRF token cookie.
  2. Attacker waits 3+ seconds to satisfy the render-time trap.
  3. Attacker sends POST /api/contact with valid CSRF headers/cookie and a spoofed X-Forwarded-For (e.g., 192.0.2.1).
  4. Attacker repeats the POST, changing X-Forwarded-For each time (e.g., 192.0.2.2, 192.0.2.3, ...).
  5. Each request is treated as a new IP, allowing unlimited submissions.

Recommended Fix

Restrict forwarded-header trust to known proxy IP ranges (or disable forwarded header processing if no reverse proxy is used). Example:

server.forward-headers-strategy=framework
server.tomcat.internal-proxies=10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|192\\.168\\.\\d{1,3}\\.\\d{1,3}|172\\.(1[6-9]|2[0-9]|3[0-1])\\.\\d{1,3}\\.\\d{1,3}

History

This bug was introduced in commit e34b631. The commit added IP-based rate limiting for the contact form using getRemoteAddr() without configuring trusted proxies, so the existing server.forward-headers-strategy=framework setting (added in 25bd9a4 to handle reverse proxy deployments) blindly trusts all X-Forwarded-For headers, allowing spoofing.

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