See how 9router compares to other vendors in security performance
Summary The PATCH /api/settings endpoint writes the entire request body to persistent settings without a field whitelist. An authenticated user can set security-critical fields that are not meant to be modifiable here — notably requireLogin. Setting requireLogin: false disables authentication for the whole application, exposing all protected routes (e.g. /api/keys, /api/providers) to unauthenticated access.
Details Root cause is unfiltered mass assignment (CWE-915):
- src/app/api/settings/route.js (PATCH handler) parses the body and passes it to updateSettings(body), with special handling only for newPassword and oidcClientSecret. All other fields pass through. - src/lib/db/repos/settingsRepo.js — updateSettings does next = { ...current, ...updates }, so any key in the body overwrites stored settings, including requireLogin, tunnelDashboardAccess, authMode. - src/dashboardGuard.js — isAuthenticated returns true whenever settings.requireLogin === false, bypassing auth on all protected routes.
This is distinct from CVE-2026-5842 (CWE-285, pre-auth bypass on /api, patched in 0.3.75). This finding requires a valid authenticated session and abuses input handling, not missing authentication.
PoC Instance on localhost:20128, default password 123456.
1. Authenticate, capture session: POST /api/auth/login body {"password":"123456"} → 200 {"success":true} 2. Mass-assign with the authenticated session: PATCH /api/settings body {"requireLogin":false} → 200, response confirms "requireLogin":false 3. Verify bypass with NO session/credentials: GET /api/keys → 200, returns full API key list unauthenticated 4. Cleanup (authenticated): PATCH /api/settings body {"requireLogin":true} → GET /api/keys returns 401 again
Impact Post-authentication mass assignment. Any authenticated user (including one using the default password) can disable authentication globally, then read all stored API keys and provider connection data without credentials, and toggle tunnel/dashboard exposure. Escalates to remote full compromise when chained with the default password 123456 on an instance exposed via tunnel (tunnelDashboardAccess defaults to true).
Suggested fix Whitelist user-configurable fields in the PATCH handler; move security-critical fields (requireLogin, tunnelDashboardAccess, authMode) to a dedicated endpoint requiring re-authentication (current-password re-entry), mirroring the existing DB export/import re-auth flow.
Summary
9router treats local loopback requests as trusted and allows access to /v1/ without an API key. In a documented/common reverse-proxy deployment where nginx forwards public traffic to the backend via 127.0.0.1, external non-Origin requests are misclassified as local. This allows unauthenticated access to /v1 APIs such as /v1/models, and may allow abuse of configured upstream provider credentials depending on the enabled providers.
Details
- Affected version / commit: 9router v0.4.80 @ b282f05. - Deployment precondition: a same-host reverse proxy (e.g. nginx) forwarding public traffic to the backend on 127.0.0.1 / localhost. This mirrors the documented cloud deployment (proxypass http://localhost:20128 with X-Real-IP / X-Forwarded-For). - Observed behaviour: - The direct backend (direct-backend, port 18081) returns 401 for /v1/models without an API key. - A direct request that spoofs X-9r-Real-IP: 127.0.0.1 still returns 401: the custom server deletes the client-supplied header and overwrites it with the real socket address, so naive header spoofing does not work against the direct backend. - The proxied path (reverse-proxy, port 18080) returns 200 with the full model catalog for the same /v1/models request without any API key. - A proxied request that carries an Origin header returns 401. The bypass therefore primarily affects curl / SDK / server-side / non-browser clients, which do not send Origin. - Root cause: the backend's local/remote decision relies on perceived socket/loopback locality after reverse proxying. Because nginx connects to the backend from 127.0.0.1, the backend stamps a loopback client address for every internet client and treats the request as local, skipping the /v1 API-key requirement. The forwarded X-Real-IP / X-Forwarded-For headers that carry the true client IP are ignored for this decision. - This is not a simple client header-spoofing issue (the direct-spoof control above proves header spoofing is rejected); it is a property of how loopback proxy traffic is trusted.
Proof of Concept
This repository is a self-contained Docker Compose reproduction. No real provider is called and no real API key is required.
1. Build and start the stack: bash docker compose up --build 2. Direct baseline (no API key): bash curl -i http://127.0.0.1:18081/v1/models 3. Direct spoof control: bash curl -i -H "X-9r-Real-IP: 127.0.0.1" http://127.0.0.1:18081/v1/models 4. Reverse-proxy bypass (no API key): bash curl -i http://127.0.0.1:18080/v1/models 5. Reverse-proxy Origin control: bash curl -i -H "Origin: http://evil.example" http://127.0.0.1:18080/v1/models
Expected evidence
| Request | Result | |---------|--------| | Direct 18081, no key | 401 Unauthorized ({"error":"API key required for remote API access"}) | | Direct 18081, X-9r-Real-IP: 127.0.0.1 spoof | 401 Unauthorized | | Proxied 18080, no key | 200 OK with the full model catalog | | Proxied 18080, with Origin | 401 Unauthorized |
Impact
- Unauthenticated access to the /v1 API surface in the affected reverse-proxy deployment. - Model enumeration via /v1/models. - Possible abuse of the operator's configured upstream provider credentials through /v1/chat/completions and other /v1 proxy endpoints (the attacker spends the operator's provider quota/keys without holding any key of their own). - Actual impact depends on which providers are configured and how the instance is exposed to the public internet. - The attacker requires no API key.
Suggested Fix
- Do not use client/proxy/socket IP locality as an authentication bypass. - Require an API key by default for /v1/ on public listeners. - If local trust is genuinely needed, bind it to an unguessable server-generated secret or to a Unix domain socket that is only accessible locally — not to "the connection looks like loopback". - When running behind reverse proxies, use an explicit trusted-proxy configuration and a real client-IP derivation (e.g. a vetted X-Forwarded-For chain), and never treat all loopback proxy traffic as end-user-local. - Document a secure reverse-proxy configuration for operators.
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 (MAXFAILSBEFORELOCK = 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.
9router 0.4.59 (fixed in 0.4.60) contains a chain of vulnerabilities: a hardcoded default password (123456) that authenticates any fresh installation, a bypass of the LOCALONLY network gate via a spoofed Host header, and unvalidated arguments passed to childprocess.spawn() when registering MCP plugins. A remote, unauthenticated attacker can log in with the default credential, spoof the Host header to reach local-only routes, and register a malicious MCP plugin (e.g. node -e <payload>) to achieve arbitrary code execution on the host operating system when the plugin's SSE endpoint is triggered.
9Router through version 0.4.41 contains an unauthenticated access vulnerability that allows remote attackers to interact with provider management API endpoints by sending requests without any credentials due to missing authentication middleware in the Next.js API routes under src/app/api/providers/. Attackers can enumerate, create, modify, or delete provider connections to expose partial credentials, OAuth tokens, and API keys, redirect AI traffic to attacker-controlled servers, or cause complete denial of service by deleting all provider connections.