GitHub Advisoryadvisory · npm

GHSA-32gc-64m7-hj7v 9Router has a Login Brute-Force Lockout Bypass via Spoofable X-9r-Real-Ip Header

MEDIUM Published 2026-09-22 Modified 2026-09-22 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)

Weakness · CWE (2)

  • CWE-307 Improper Restriction of Excessive Authentication Attempts weakness
  • CWE-807 Reliance on Untrusted Inputs in a Security Decision weakness

References