Vaultwarden, default X-Real-IP trusted-local bypasses login rate limits
Vaultwarden 1.37.3 defaults IP_HEADER to X-Real-IP and IP_HEADER_TRUSTED_PROXIES to local. Any non-global TCP peer is treated as a reverse proxy, so a client X-Real-IP becomes the login rate-limit key. Stock Docker published-port NAT is RFC1918. Unique headers never share a burst. 2FA after a correct password still runs. No CVE yet.
- Name
- Vaultwarden, default X-Real-IP trusted-local bypasses login rate limits
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Vaultwarden
- Affected
- Vaultwarden 1.37.3 (eb212e23). Image vaultwarden/server:1.37.3. Docker published-port / userland-proxy / Desktop NAT, or any RFC1918 peer. Unauthenticated.
- Published
- 02 Oct 2026
- Updated
- 02 Oct 2026
- Tags
- 0-day, vaultwarden, rate-limit, ip-spoof, cwe-307, cwe-290, unauthenticated
docker called from the next subnet. vaultwarden called that a proxy.
The proof of concept is on GitHub: abraxas/vaultwarden-ip-header-spoof (loopback client; @abraxas_null). The lab stack is lab/: docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.
This is Vaultwarden 1.37.3 (eb212e23), dani-garcia. No CVE yet. CWE-307 / CWE-290. 7.5 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N). Unauthenticated. 2FA after a correct master password still runs.
What an attacker can do
The attacker removes IP-keyed brute-force protection. They send a fresh X-Real-IP on every try. The governor is a DashMap keyed on that address, so burst and period never trip.
- Guess master passwords without a 429.
POST /identity/connect/tokenpassword-grant. Default burst is 10 per 60 seconds per IP. Unique headers reset the bucket. - Burn email-2FA and admin-token governors the same way.
/api/two-factor/send-email-login,POST /admin, plus unauth hint / prelogin / delete-recover / auth-request / Send access all key onClientIp. - Reach the container through the published port. Stock
docker run -p 80:80and compose80:80make the peer a bridge or Desktop NAT address.trusted=localis!is_global(peer). That matches. - 2FA after a correct master password still runs. This is unlimited guesses, not a second-factor skip.
A reverse proxy that replaces X-Real-IP with the real client, and a host bind whose peer is a global address, both keep the governor honest. The hole is the default plus a published port with no overwrite.
How I found it
I pinned 1.37.3 after the 1.35.x / 1.36.0 / 1.37.0 GHSAs. GHSA-c5rv put send-email-login on LIMITER_LOGIN. The leftover is how ClientIp is chosen.
config.rs defaults ip_header to X-Real-IP and ip_header_trusted_proxies to local. auth.rs ip_header_is_trusted treats local as !is_global(remote). ratelimit.rs keys LIMITER_LOGIN on that IpAddr. Docker bridge, Desktop VM NAT, and loopback are all non-global.
I stood up stock vaultwarden/server:1.37.3 on loopback 127.0.0.1:18170:80 with no overwrite-proxy, plus a second instance on 18171 with IP_HEADER=none. Registered user@lab.invalid. Password-grant with a wrong password and no header 429'd at request 11 (burst 10). The same grant with X-Real-IP: 198.51.100.{n} unique per try stayed HTTP 400 fourteen times, zero 429s. On the none instance, unique 203.0.113.{n} still 429'd at 11.
SUCCESS VAULTWARDEN-IP-HEADER-SPOOF nohdr-429-at=11 spoof-400=14 spoof-429=0 none-429-at=11 VAULTWARDEN-IP-HEADER-SPOOF-WITNESSWrong turns already recorded: the 1.37.3 image was not local, so the first compose waited on a pull; first /alive was an empty reply while Rocket bound, then 200 on wait 2; dummy register JSON (masterPasswordHash + a short key) was enough, no long-key retry. A reverse shell. Theatre. The oracle is 429 without the header and 400 with a unique one.
local means the published port
ip_header: String, true, def, "X-Real-IP".to_owned();
ip_header_trusted_proxies: String, true, def, "local".to_owned();if trusted.eq_ignore_ascii_case("local") {
return !crate::util::is_global(remote);
}Default IP_HEADER to none, or default IP_HEADER_TRUSTED_PROXIES to an empty list / explicit proxy CIDR. local as "any RFC1918 peer" is the published-port case. Existence of a forwarding header is not proof of a proxy.
I am @abraxas_null. Site abraxaslabs.tech. GitHub abraxas. Mail abraxas.null@proton.me.