Reset-password rate limit trivially bypassed via spoofed X-Forwarded-For
The express-rate-limit middleware on /rest/user/reset-password keys requests off the client-controlled X-Forwarded-For header, so an attacker can rotate that header per request and brute-force security answers without ever hitting the 100/5-minute cap.
Lines 315–319:
app.use('/rest/user/reset-password', rateLimit({
windowMs: 5 * 60 * 1000,
max: 100,
keyGenerator ({ headers, ip }: { headers: any, ip: any }) { return headers['X-Forwarded-For'] ?? ip }
}))
app.enable('trust proxy') is set unconditionally on line 314, and the keyGenerator reads headers['X-Forwarded-For'] directly. Since this header is supplied by the client, an attacker who sends a different X-Forwarded-For value on each request gets a fresh bucket every time — the limiter is effectively disabled for anyone aware of the header. Header lookup is also case-sensitive in Node (req.headers is lower-cased), so the literal key 'X-Forwarded-For' will be undefined for any honestly-proxied request and silently fall back to ip, while an attacker can set the exact-case header via raw HTTP to dominate the key space.
Given the reset flow (per Juice-Shop and the imported securityQuestion/SecurityAnswer models) gates password reset on a security-answer knowledge factor, the absence of an effective rate limit/lockout (criterion 4 in the brief) directly enables online brute-force of those low-entropy answers.
Rotate the spoofed header per request — each one creates a new rate-limit bucket
for i in $(seq 1 100000); do curl -s -X POST https://target/rest/user/reset-password \ -H "X-Forwarded-For: 10.0.$((i/256)).$((i%256))" \ -H 'Content-Type: application/json' \ -d "{\"email\":\"victim@example.com\",\"answer\":\"guess$i\",\"new\":\"x\",\"repeat\":\"x\"}" done
Unauthenticated attackers can brute-force security answers (or reset tokens) for any user at unlimited throughput, defeating the only quantitative defence the application places in front of the security-question-based reset flow. Leads to full account takeover.
The keyGenerator at lines 315-319 returns headers['X-Forwarded-For'] ?? ip. Because app.enable('trust proxy') is set on line 314, Express's req.ip is itself derived from the (attacker-controlled) X-Forwarded-For header, so even when the case-sensitive headers['X-Forwarded-For'] lookup falls back to ip, the rate-limit key is still client-controllable — rotating the XFF header per request yields fresh buckets. The reset-password route (app.post('/rest/user/reset-password', utils.asyncHandler(resetPassword()))) is the only quantitative defense in front of the security-answer–gated reset flow, so the bypass directly enables online brute-force of low-entropy answers. The PoC shape (POST with email/answer/new/repeat JSON) matches Juice-Shop's reset endpoint.
The /rest/user/reset-password endpoint is mounted at line ~559 with no auth middleware in front of it, and the rate limiter at lines 315–319 keys off headers['X-Forwarded-For'] — a fully client-controlled header — so any remote, unauthenticated attacker can rotate the header per request to brute-force low-entropy security answers for arbitrary users (network-reachable, no prep, no victim interaction). Because the brute force can target any account (including admin), the worst plausible outcome is full takeover of arbitrary accounts via the password-reset sink, yielding total disclosure (C:H), the ability to overwrite the victim's password (I:H), and effective denial of access to the legitimate owner once their password is changed (A:H). Impact stays within the Juice-Shop application authority, so Scope is Unchanged. The case-sensitive 'X-Forwarded-For' lookup against Node's lower-cased req.headers only further weakens the limiter and does not raise complexity for the attacker, who sends raw HTTP with the exact-case header.
- CWE-307
- CWE-799
- OWASP ASVS V11.1.4
- https://owasp.org/www-community/controls/Blocking_Brute_Force_Attacks