Skip to content

The Client IP You Can Actually Trust

Adityo Guni Waluyo

The first X-Forwarded-For entry is attacker-controlled. Which address behind Cloudflare and nginx actually deserves a rate limiter's trust.

TL;DR

A burst of password guesses on an admin login exposed a flaw: the rate limiter trusted an attacker-controlled X-Forwarded-For entry. The fix prefers Cloudflare's CF-Connecting-IP header, then the last proxy entry, then X-Real-IP, and finally the raw connection address. Per-account keys pairing IP with email, 429 responses with Retry-After, and bounded memory keep the limiter effective.

An internal admin portal's login endpoint recently experienced a sustained burst of password guesses. The immediate operational response was to deploy a rate limiter middleware. However, the engineering team quickly realized a fundamental truth: the limiter is only as honest as the client IP address it reads. If the extraction logic is flawed, the rate limiter becomes a decorative shield rather than a functional barrier, silently allowing abuse to continue.

The initial implementation followed a common tutorial pattern: extracting the first entry of the X-Forwarded-For header. This approach operates on the assumption that the leftmost IP is always the originating user. It is a widespread heuristic, but it is dangerously naive in modern infrastructure where the edge is exposed to the public internet.

Behind a Cloudflare and nginx stack, the first X-Forwarded-For entry is fully attacker-controlled. An attacker can simply craft a request containing X-Forwarded-For: 203.0.113.7 [1]. nginx, by design, appends the real connection IP as the last entry using $proxy_add_x_forwarded_for, which appends $remote_addr [3]. When traffic consistently arrives via Cloudflare, the CF-Connecting-IP header is set by the edge itself, providing the most direct and verifiable claim to the origin [2]. Relying on the first entry essentially allows the attacker to dictate their own rate limit identity.

The X-Forwarded-For header is append-only. The leftmost entry was written by the party the origin controls least: the client itself. Trust is a property of who wrote the last entry in the chain, not merely the presence of the header name. Every proxy in the chain should ideally append its view of the connecting IP, meaning the rightmost trusted proxy's addition is the only one that can be topologically verified.

The Resolution Order and Its Guards

The resolution order in the updated clientip.go reflects this reality. It checks CF-Connecting-IP first. If absent, it falls back to the last X-Forwarded-For entry, followed by X-Real-IP, and finally defaults to the TCP RemoteAddr (with the port stripped using net.SplitHostPort). This cascading fallback ensures that the most authoritative source is always preferred.

In middleware/ratelimit.go, a fixed-window in-memory limiter enforces the boundary. The Allow(key) function returns a boolean and a time.Duration. Upon denial, the server responds with HTTP 429 [4], a JSON error code RATE_LIMITED, the error message terlalu banyak percobaan; coba lagi nanti taken verbatim from the API contract, and a Retry-After header in seconds (calculated as the window remainder plus one) [5]. This precise timing prevents clients from hammering the endpoint immediately after a window resets.

Per-Account Keys and the 429 Contract

The LoginKeyFunc constructs the rate limit key as prefix|IP|email. The email is read directly from the JSON body. To prevent stream exhaustion, the body is drained through an io.LimitReader capped at 64KB [7] and then restored via io.NopCloser so the downstream handler can still process the payload. This architectural choice ensures that one locked account does not unfairly punish other legitimate accounts operating behind the same shared IP, such as a corporate NAT gateway [6].

To prevent memory exhaustion during aggressive key flooding, the sweepLocked function drops expired windows once the map exceeds 10,000 keys. This guarantees bounded memory usage even under sustained attack conditions, trading minor accuracy for system stability.

The consequence of the old first-entry parsing was severe. An attacker could rotate a fake X-Forwarded-For first entry on every request, making each attempt appear to originate from a completely new client. The limiter would never trip. Internal view deduplication counters and the review ip_address column suffered from the exact same vulnerability, rendering historical analysis unreliable and masking the true scale of the attack.

When requests traverse multi-layer proxies beyond two hops, how does the chain of trust degrade, and what mechanisms can definitively anchor the origin?

Sources

[1] MDN, X-Forwarded-For header, developer.mozilla.org

[2] Cloudflare Fundamentals, HTTP request headers, developers.cloudflare.com

[3] nginx, Module ngx_http_proxy_module ($proxy_add_x_forwarded_for), nginx.org

[4] RFC 6585, Additional HTTP Status Codes, rfc-editor.org

[5] RFC 9110, HTTP Semantics, Retry-After, httpwg.org

[6] OWASP Authentication Cheat Sheet, Login Throttling, cheatsheetseries.owasp.org

[7] Go io package, LimitReader, pkg.go.dev

Related articles