#5862·kanboard

Brute-force lockout is keyed on the leftmost X-Forwarded-For value (incomplete fix for CVE-2025-52576)

Author: krishnextgencyberCreated Jul 26, 2026Updated Jul 26, 2026

Summary

Request::getIpAddress() honours proxy headers only when the peer is a configured trusted proxy — the control added for CVE-2025-52576, and that gate is correct. It then takes the first entry of X-Forwarded-For. A reverse proxy appends the real client on the right, so the leftmost entry is the value the client supplied. The IP-keyed CAPTCHA and lockout are therefore stored under an attacker-chosen key.

Affected code

app/Core/Http/Request.php::getIpAddress():

php
foreach ($keys as $key) {
    if ($this->getServerVariable($key) !== '') {
        foreach (explode(',', $this->server[$key]) as $ipAddress) {
            $ipAddress = trim($ipAddress);
            if (filter_var($ipAddress, FILTER_VALIDATE_IP)) {
                return $ipAddress;          // leftmost wins
            }
        }
    }
}

Consumer — app/Model/CaptchaModel.php, keyed by that value:

php
$data[$ipAddress]['failed_login']++;
if ($data[$ipAddress]['failed_login'] >= BRUTEFORCE_CAPTCHA) { ... lock ... }

Preconditions

TRUSTED_PROXY_HEADERS and TRUSTED_PROXY_NETWORKS configured. Both default to empty, so a default install is not affected — this applies to the documented reverse-proxy deployment. No authentication required to exploit.

Steps to reproduce

Config used: TRUSTED_PROXY_HEADERS="HTTP_X_FORWARDED_FOR", TRUSTED_PROXY_NETWORKS="172.16.0.0/12,127.0.0.1/32".

  1. What the application receives behind the proxy (the proxy appends the real peer): HTTP_X_FORWARDED_FOR = 203.0.113.77, 172.17.0.1, REMOTE_ADDR = 172.17.0.1
  2. getIpAddress() resolves (probe inside the container with the real config loaded):
    XFF=1.2.3.4, 172.17.0.1           -> 1.2.3.4
    XFF=8.8.8.8, 9.9.9.9, 172.17.0.1  -> 8.8.8.8
    XFF=not-an-ip, 5.5.5.5            -> 5.5.5.5
  3. Send failed logins with X-Forwarded-For: 203.0.113.77 (on both the GET and the POST). The captcha_data setting gains a new key created from the forged value:
    json
    {"172.17.0.1": {"failed_login":5,"lock_expiration_date":1785001438},
     "203.0.113.77": {"failed_login":2,"expiration_date":0}}

Rotating the header per attempt means no key reaches BRUTEFORCE_CAPTCHA / BRUTEFORCE_LOCKDOWN.

Impact

Restores the capability CVE-2025-52576 was filed to remove: unthrottled credential guessing and username enumeration from a single host on proxy deployments. The per-username counter (users.nb_failed_login) is unaffected — this defeats the IP-based half, which is the half that stops password-spraying across many usernames. CVSS 3.1: 6.5 (AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N), CWE-290/CWE-307.

Fix

Walk the chain from the right, discarding hops you control, and take the first untrusted address:

php
$addresses = array_map('trim', explode(',', $this->server[$key]));
for ($i = count($addresses) - 1; $i >= 0; $i--) {
    $addr = $addresses[$i];
    if (! filter_var($addr, FILTER_VALIDATE_IP)) { continue; }
    if ($this->isIpInNetworks($addr, $trustedProxyNetworks)) { continue; }   // hop we control
    return $addr;                                                            // first untrusted = client
}
return $this->getClientIpAddress();

Alternatively prefer a single-valued header the proxy fully controls (X-Real-IP, which the official image already sets) over the appendable list.

Verified against v1.2.52 and the current release v1.2.53 (2026-07-24) — file byte-identical in both. Reported as a regular bug per SECURITY.md.