CVE-2026-49352: Verified Reproduction
CVE-2026-49352: 9router hardcoded default fallback JWT secret allows authentication bypass
CVE-2026-49352 is verified against decolua/9router · github. Affected versions: >=0.2.21 <=0.4.41. Fixed in 0.4.45. Vulnerability class: Auth Bypass. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00217.
What Is CVE-2026-49352?
CVE-2026-49352 is a critical authentication bypass caused by hard-coded credentials (CWE-798) in the 9router npm package (decolua/9router). When the JWT_SECRET environment variable is unset, 9router uses a publicly known default secret, letting an unauthenticated attacker forge a valid session. Pruva reproduced it (reproduction REPRO-2026-00217).
CVE-2026-49352 Severity & CVSS Score
CVE-2026-49352 is rated critical severity, with a CVSS base score of 9.8 out of 10.
Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
Affected decolua/9router Versions
decolua/9router · github versions >=0.2.21 <=0.4.41 are affected.
How to Reproduce CVE-2026-49352
pruva-verify REPRO-2026-00217 curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00217/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-49352
- reached the target end-to-end
- full exploit chain demonstrated
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
auth_token cookie containing HS256 JWT signed with hardcoded secret '9router-default-secret-change-me'
- GET /dashboard and GET /api/keys with forged auth_token cookie against real 9router Next.js server (v0.4.41) running WITHOUT JWT_SECRET env var
reproduction_steps.sh How the agent worked
Root Cause and Exploit Chain for CVE-2026-49352
9router (npm package 9router, GitHub decolua/9router) is a Next.js 16 web
application that serves an AI-router dashboard on port 20128. In versions
=0.2.21 and <=0.4.41, the JWT signing/verification secret is derived from
process.env.JWT_SECRET || "9router-default-secret-change-me". When an operator runs the app without setting theJWT_SECRETenvironment variable (the default out-of-the-box configuration), the application falls back to a publicly known, hardcoded string. Any unauthenticated remote attacker who knows this string can forge a valid HS256 JWT, set it as theauth_tokencookie, and bypass authentication entirely — accessing the dashboard and all protected API endpoints without credentials.
- Package/component affected:
9router-app(the Next.js dashboard application shipped by the9routernpm package / Docker image), specifically the JWT session modulesrc/lib/auth/dashboardSession.js(v0.4.31–v0.4.41) and previouslysrc/app/api/auth/login/route.js+src/middleware.js(v0.2.21–v0.4.30). - Affected versions: >=0.2.21 and <=0.4.41 (verified against v0.4.41).
- Risk level: Critical. Complete authentication bypass. An unauthenticated
remote attacker gains full access to the dashboard UI and every protected API
endpoint (
/api/keys,/api/settings/*,/api/providers/client,/api/cli-tools/*,/api/mcp/*,/api/shutdown, etc.), exposing API keys, provider credentials, settings, and allowing shutdown of the service.
Impact Parity
- Disclosed/claimed maximum impact: Authentication bypass — unauthenticated
remote attacker forges
auth_tokencookie and gains full access to dashboard and API (authz_bypass, critical). - Reproduced impact from this run: Full authentication bypass demonstrated
against the real 9router Next.js server (v0.4.41) running without
JWT_SECRET. A forged HS256 JWT signed with the known secret produced:GET /dashboard→ HTTP 200 (full 25 KB dashboard HTML served)GET /api/keys→ HTTP 200, body{"keys":[]}(protected API access)- No-cookie controls correctly returned 307 (redirect to
/login) and 401.
- Parity:
full— the claimed unauthenticated auth bypass was reproduced end-to-end on the production HTTP path, and the fixed version (v0.4.44) was shown to reject the identical forged token (negative control). - Not demonstrated: Not applicable; the claim is auth bypass (not code execution), and auth bypass was fully demonstrated.
Root Cause
The JWT session module computes its HMAC secret at module-load time:
// src/lib/auth/dashboardSession.js (v0.4.41)
import { SignJWT, jwtVerify } from "jose";
const SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "9router-default-secret-change-me"
);
The fallback literal "9router-default-secret-change-me" is a constant baked
into the published source. The dashboard middleware
(src/proxy.js → src/dashboardGuard.js) protects /dashboard/:path* and
sensitive API paths by calling verifyDashboardAuthToken(token), which runs
jwtVerify(token, SECRET) from the jose library. Because the fallback secret
is identical on every installation that omits JWT_SECRET, an attacker can
locally reproduce the exact SECRET bytes and sign an arbitrary JWT that the
server will accept as genuine.
The login route (src/app/api/auth/login/route.js) issues tokens via
createDashboardAuthToken, which signs { authenticated: true } with HS256 and
a 24 h expiry. An attacker simply replicates this token offline — no password,
no network interaction with the target is needed beyond submitting the cookie.
Fix commit: fe3ce25ae3cda48c0702c2d452e17f6ec214009d ("Update JWT_SECRET
handling", released in v0.4.44/v0.4.45). The fix replaces the hardcoded
fallback with loadJwtSecret(), which (1) uses JWT_SECRET if set, else (2)
reads a persisted secret from <DATA_DIR>/jwt-secret, else (3) generates a
random 32-byte hex secret via crypto.randomBytes(32) and writes it to disk
(mode 0600). Each install therefore gets a unique, unguessable secret.
Reproduction Steps
- Script:
bundle/repro/reproduction_steps.sh(self-contained, portable, reuses the durable project cache at<project_cache_dir>/repoand<project_cache_dir>/repo-fixed). - What the script does:
- Checks out / reuses the vulnerable 9router v0.4.41 and the fixed v0.4.44
from the cached git mirror, building each with
npm run build(next build --webpack) if a build is not already present. - Starts the real 9router Next.js production server
(
next start -p 20128 -H 127.0.0.1) for the vulnerable version withoutJWT_SECRETset, waits for it to become healthy (/api/auth/statusreturns 200). - Forges an HS256 JWT (via
bundle/repro/forge_jwt.py) with payload{ "authenticated": true, "iat": <now>, "exp": <now+24h> }signed with the known secret9router-default-secret-change-me, and sends it as theauth_tokencookie toGET /dashboardandGET /api/keys. - Repeats the same forged-cookie requests against the fixed v0.4.44 server
(also without
JWT_SECRET) as a negative control. - Writes
bundle/repro/runtime_manifest.jsonand exits 0 only when the vulnerable build accepts the forged token (200) and the fixed build rejects it (307 / 401).
- Checks out / reuses the vulnerable 9router v0.4.41 and the fixed v0.4.44
from the cached git mirror, building each with
- Expected evidence of reproduction:
- Vulnerable:
GET /dashboardwith forged cookie → HTTP 200 (dashboard HTML, 25 268 bytes);GET /api/keyswith forged cookie → HTTP 200{"keys":[]}; no-cookie → 307 / 401. - Fixed:
GET /dashboardwith forged cookie → HTTP 307 redirect to/login;GET /api/keyswith forged cookie → HTTP 401{"error":"Unauthorized"}.
- Vulnerable:
Evidence
- Log files:
bundle/logs/reproduction_steps.log— full annotated run log.bundle/logs/vuln_server.log— vulnerable server startup (Next.js 16.2.10,Ready,[DB] Driver: better-sqlite3).bundle/logs/fixed_server.log— fixed server startup.
- HTTP artifacts:
bundle/artifacts/forged_jwt.txt— the forged token.bundle/artifacts/http/vuln_forged_hdr.txt—HTTP/1.1 200 OK(dashboard served to forged cookie on vulnerable build).bundle/artifacts/http/vuln_forged_resp.html— 25 268 bytes of dashboard HTML (<!DOCTYPE html>...).bundle/artifacts/http/vuln_api_forged_resp.txt—{"keys":[]}.bundle/artifacts/http/vuln_nocookie_hdr.txt—307redirect to/login.bundle/artifacts/http/fixed_forged_hdr.txt—HTTP/1.1 307 Temporary Redirect,location: /login(forged token rejected by fixed build).bundle/artifacts/http/fixed_api_forged_resp.txt—{"error":"Unauthorized"}.
- Key excerpt (vulnerable, forged cookie):
VULN /dashboard (forged auth_token)-> 200 (expect 200 = BYPASS) VULN /api/keys (forged auth_token) -> 200 (expect 200 = API access) - Key excerpt (fixed negative control):
FIXED /dashboard (forged auth_token)-> 307 http://127.0.0.1:20128/login (rejected) FIXED /api/keys (forged auth_token) -> 401 (rejected) - Environment: Node v24.18.0, npm 11.16.0, Next.js 16.2.10, jose 6.x,
Linux x86_64, server bound to
127.0.0.1:20128,DATA_DIRisolated per build,JWT_SECRETintentionally unset.
Recommendations / Next Steps
- Upgrade to 9router >=0.4.45 (contains the fix). The fix generates a
random per-install secret when
JWT_SECRETis unset. - Set
JWT_SECRETto a long, random value via environment variable in all deployments (Docker, systemd, npm global). Never rely on the fallback. - Rotate any
auth_tokencookies / API keys issued by deployments that ran withoutJWT_SECRET, since they were effectively publicly forgeable. - Restrict network exposure: bind the dashboard to localhost or place it behind authenticated reverse proxies; the app already has loopback/Origin gating for some spawn-capable routes, but the dashboard itself was reachable.
- Testing recommendation: add a regression test that asserts the app
refuses to start (or refuses to verify tokens) when no
JWT_SECRETand no persisted secret exist, and a test that a token signed with the old default literal is rejected after upgrade.
Additional Notes
- Idempotency: The script was run twice consecutively; both runs produced identical results (vulnerable 200/200, fixed 307/401) and exited 0. Builds are cached in the durable project cache and reused on subsequent runs.
- Negative control: The fixed v0.4.44 build (commit
fe3ce25ae) was compiled and run under identical conditions (noJWT_SECRET, same forged token). Its middleware contains no occurrence of the hardcoded literal (verified via grep of.next/server/middleware.js), and it rejected the forged token, confirming the fix is effective and that the bypass is specific to the hardcoded-secret versions. - Library-level cross-check: The forged token was independently verified
with the real
joselibrary (jwtVerify→ OK with the known secret; → "signature verification failed" with a random secret) before the HTTP proof, matching the exact verification path used byverifyDashboardAuthToken. - Scope note:
next startprints a warning underoutput: standalonerecommendingnode .next/standalone/server.js; this is cosmetic —next startcorrectly serves the built app and runs the proxy/middleware, as evidenced by the 200/307/401 responses.
CVE-2026-49352 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
ls -la /data/pruva/project-cache/4363a60d-57f6-4170-9aa0-2e4f54c86001/ 2>/dev/null; echo "---repo-mirrors---"; ls -la /data/pruva/project-cache/4363a60d-57f6-4170-9aa0-2e4f54c86001/repo-mirrors/ 2>/dev/null; echo "---done---"total 0 drwxr-xr-x 1 vscode vscode 24 Jul 3 14:29 . drwxr-xr-x 1 vscode vscode 2254 Jul 3 14:29 .. drwxr-xr-x 1 vscode vscode 0 Jul 3 14:29 repo-mirrors ---repo-mirrors--- total 0 drwxr-xr-x 1 vscode vscode 0 Jul 3 14:29 . drwxr-xr-x 1 vscode vscode 24 Jul 3 14:29 ..
npm view 9router versions --json 2>/dev/null | head -100; echo "===LATEST==="; npm view 9router version 2>/dev/null; echo "===DIST==="; npm view 9router dist.tarball 2>/dev/null{"cwd":"/data/pruva/runs/67f28c86-fec1-4100-b783-c29256359392","duration_ms":1003,"exit_code":0,"stderr":"","stdout":"[\n \"0.2.13\",\n \"0.2.14\",\n \"0.2.15\",\n \"0.2.16\",\n \"0.2.17\",\n \"0.2.18\",\n \"0.2.19\",\n \"0.2.20\",\n \"0.2.21\",\n \"0.2.22\",\n \"0.2.23\",\n \"0.2.24\",\n \"0.2.25\",\n \"0.2.26\",\n \"0.2.27\",\n \"0.2.29\",\n \"0.2.30\",\n \"0.2.31\",\n \"0.2.32\",\n \"0.2.33\",\n \"0.2.34\",\n \"0.2.36\",\n \"0.2.37\",\n \"0.2.38\",\n \"0.2.39\",\n \"0.2.40\",\n \"0.2.41\",\n \"0.2.42\",\n \"0.2.43\",\n \"0.2.44\",\n \"0.2.45\",\n \"0.2.46\",\n \"0.2.47\",\n \"0.2.48\",\n \"0.2.49\",\n \"0.2.50\",\n \"0.2.51\",\n \"0.2.52\",\n \"0.2.53\",\n \"0.2.54\",\n \"0.2.56\",\n \"0.2.57\",\n \"0.2.58\",\n \"0.2.59\",\n \"0.2.60\",\n \"0.2.61\",\n \"0.2.62\",\n \"0.2.63\",\n \"0.2.64\",\n \"0.2.65\",\n \"0.2.66\",\n \"0.2.67\",\n \"0.2.68\",\n \"0.2.70\",\n \"0.2.71\",\n \"0.2.72\",\n \"0.2.73\",\n \"0.2.74\",\n \"0.2.75\",\n \"0.2.76\",\n \"0.2.77\",\n \"0.2.78\",\n \"0.2.79\",\n \"0.2.80\",\n \"0.2.81\",\n \"0.2.83\",\n \"0.2.84\",\n \"0.2.87\",\n \"0.2.88\",\n \"0.2.89\",\n \"0.2.90\",\n \"0.2.91\",\… [truncated]cd /data/pruva/project-cache/4363a60d-57f6-4170-9aa0-2e4f54c86001/repo-mirrors && git clone --mirror https://github.com/decolua/9router.git 9router.git 2>&1 | tail -20; echo "EXIT=$?"Cloning into bare repository '9router.git'... EXIT=0
Artifacts and Evidence for CVE-2026-49352
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-49352
Upgrade decolua/9router · github to 0.4.45 or later.
FAQ: CVE-2026-49352
How does an attacker exploit CVE-2026-49352?
Which versions of 9router are affected by CVE-2026-49352, and where is it fixed?
How can I reproduce CVE-2026-49352?
References for CVE-2026-49352
Authoritative sources for CVE-2026-49352 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.