Research/vaultwarden-ip-header-spoof
0-dayNo CVEHighPublic

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/token password-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 on ClientIp.
  • Reach the container through the published port. Stock docker run -p 80:80 and compose 80:80 make the peer a bridge or Desktop NAT address. trusted=local is !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.

Plain text
SUCCESS VAULTWARDEN-IP-HEADER-SPOOF nohdr-429-at=11 spoof-400=14 spoof-429=0 none-429-at=11 VAULTWARDEN-IP-HEADER-SPOOF-WITNESS

Wrong 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

Plain text
ip_header:              String, true,   def,    "X-Real-IP".to_owned();
ip_header_trusted_proxies: String, true, def,    "local".to_owned();
Plain text
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.

Vaultwarden, default X-Real-IP trusted-local bypasses login rate limits · Abraxas Labs