Skip to content

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.

REPRO-2026-00217 decolua/9router · github Auth Bypass Jul 3, 2026 CVE entry .txt
Severity
CRITICAL
CVSS
9.8
Confidence
HIGH
Reproduced in
13m 50s
Tool calls
196
Spend
$2.59
01 · Overview

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).

02 · Severity & CVSS

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 threat level
9.8 / 10 CVSS base
Weakness CWE-798 (Use of Hard-coded Credentials) — Use of Hard-coded Credentials

Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.

03 · Affected Versions

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
or 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
Run in a VM or disposable container. This exploits a real vulnerability.
06 · Proof of Reproduction

Proof of Reproduction for CVE-2026-49352

Authorization bypass — reproduced
  • 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
Trigger

auth_token cookie containing HS256 JWT signed with hardcoded secret '9router-default-secret-change-me'

Attack chain
  1. 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
Runnable proof: reproduction_steps.sh
Captured evidence: fixed server
How the agent worked 473 events · 196 tool calls · 14 min
14 minDuration
196Tool calls
134Reasoning steps
473Events
Agent activity over 14 min
Support
18
Hypothesis
2
Repro
269
Judge
24
Variant
156
0:0013:50

Root Cause and Exploit Chain for CVE-2026-49352

Versions: >=0.2.21 and <=0.4.41 (verified against v0.4.41).

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 the JWT_SECRET environment 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 the auth_token cookie, 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 the 9router npm package / Docker image), specifically the JWT session module src/lib/auth/dashboardSession.js (v0.4.31–v0.4.41) and previously src/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_token cookie 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.jssrc/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

  1. Script: bundle/repro/reproduction_steps.sh (self-contained, portable, reuses the durable project cache at <project_cache_dir>/repo and <project_cache_dir>/repo-fixed).
  2. 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 without JWT_SECRET set, waits for it to become healthy (/api/auth/status returns 200).
    • Forges an HS256 JWT (via bundle/repro/forge_jwt.py) with payload { "authenticated": true, "iat": <now>, "exp": <now+24h> } signed with the known secret 9router-default-secret-change-me, and sends it as the auth_token cookie to GET /dashboard and GET /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.json and exits 0 only when the vulnerable build accepts the forged token (200) and the fixed build rejects it (307 / 401).
  3. Expected evidence of reproduction:
    • Vulnerable: GET /dashboard with forged cookie → HTTP 200 (dashboard HTML, 25 268 bytes); GET /api/keys with forged cookie → HTTP 200 {"keys":[]}; no-cookie → 307 / 401.
    • Fixed: GET /dashboard with forged cookie → HTTP 307 redirect to /login; GET /api/keys with forged cookie → HTTP 401 {"error":"Unauthorized"}.

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.txtHTTP/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.txt307 redirect to /login.
    • bundle/artifacts/http/fixed_forged_hdr.txtHTTP/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_DIR isolated per build, JWT_SECRET intentionally unset.

Recommendations / Next Steps

  • Upgrade to 9router >=0.4.45 (contains the fix). The fix generates a random per-install secret when JWT_SECRET is unset.
  • Set JWT_SECRET to a long, random value via environment variable in all deployments (Docker, systemd, npm global). Never rely on the fallback.
  • Rotate any auth_token cookies / API keys issued by deployments that ran without JWT_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_SECRET and 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 (no JWT_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 jose library (jwtVerify → OK with the known secret; → "signature verification failed" with a random secret) before the HTTP proof, matching the exact verification path used by verifyDashboardAuthToken.
  • Scope note: next start prints a warning under output: standalone recommending node .next/standalone/server.js; this is cosmetic — next start correctly 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.

Event 1/40
0:000:57
0:00
session startedaccounts/fireworks/routers/glm-5p2-fast · CVE-2026-49352 · REPRO-20
0:03
0:04
web search
0:06
web search
0:08
0:09
0:10
web search
0:12
0:13
0:14
web search
0:24
0:24
extract_facts
no facts extracted
0:27
0:28
0:28
supportrepro
0:38
0:40
0:40
0:40
0:41
0:41
0:41
0:43
0:43
$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 ..
0:45
web search
0:48
0:49
$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
1.0s
{"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]
0:54
$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=$?"
5.0s
Cloning into bare repository '9router.git'...
EXIT=0
0:57
08 · How to Fix

How to Fix CVE-2026-49352

Upgrade decolua/9router · github to 0.4.45 or later.

Coming soon

Step-by-step mitigation and hardening guidance for CVE-2026-49352 — configuration checks, workarounds where no patch exists, and how to verify you're protected — is on the way.

10 · FAQ

FAQ: CVE-2026-49352

How does an attacker exploit CVE-2026-49352?

Because the fallback secret '9router-default-secret-change-me' is public, an unauthenticated remote attacker can forge a valid auth_token cookie signed with it and gain full authenticated access to the dashboard — no credentials required.

Which versions of 9router are affected by CVE-2026-49352, and where is it fixed?

9router versions >= 0.2.21 and <= 0.4.41 are affected. It is fixed in 0.4.45 — upgrade to 0.4.45 or later (and always set an explicit JWT_SECRET).

How can I reproduce CVE-2026-49352?

Download the verified script from this page and run it in an isolated environment against 9router 0.2.21-0.4.41 started without JWT_SECRET. It forges an auth_token cookie using the default secret and shows authenticated access being granted.
11 · References

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.