Skip to content

CVE-2026-49352: Verified Repro With Script Download

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 Variant found 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://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 serverfixed server
Variants tested

Bypass of the CVE-2026-49352 JWT-secret fix via a sibling hardcoded default auth credential: the dashboard login password '123456' (INITIAL_PASSWORD fallback) in src/app/api/auth/login/route.js. On a fresh install (no saved password hash) an unauthenticated remote attacker POSTs {"password":"123456"} to /api/auth/logi…

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.

Variant Analysis & Alternative Triggers for CVE-2026-49352

Versions: versions (as tested):Fixed: ): default-password login still works → 200 on

The CVE-2026-49352 fix (commit fe3ce25ae, released in v0.4.44/v0.4.45) correctly removed the hardcoded JWT signing secret fallback ("9router-default-secret-change-me") and replaced it with a random per-install secret. That fix closed the "forge an auth_token cookie with the publicly-known JWT secret" vector. However, the fix did not address a sibling instance of the same underlying bug class — a second hardcoded, publicly-known default authentication credential baked into the codebase: the dashboard login password "123456".

In src/app/api/auth/login/route.js, when no password hash has been saved (the default/fresh-install state) the route accepts the literal default password:

const initialPassword = process.env.INITIAL_PASSWORD || "123456";
isValid = password === initialPassword;

An unauthenticated remote attacker who can reach the dashboard HTTP port can simply POST /api/auth/login with {"password":"123456"}, receive a valid auth_token cookie, and gain full access to /dashboard and every protected API endpoint. The login does not auto-save a password hash, so the default password stays valid until the operator manually changes it.

This variant was confirmed as a bypass: it reproduces on the JWT-fixed version (v0.4.44, commit 9e87935c0) and on the latest version (v0.4.80, commit 515e2cc43). The latest version added a "remote default-password guard", but that guard is UI-only (mustChangePassword hint consumed solely by src/app/login/page.js); the dashboard middleware never checks it, so using the issued cookie directly (any non-browser client) trivially bypasses the guard.

Fix Coverage / Assumptions

What the original fix (fe3ce25ae) changes:

  • src/lib/auth/dashboardSession.js: replaces const SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "9router-default-secret-change-me") 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 persists it (mode 0600).
  • src/dashboardGuard.js / src/proxy.js: add /api/translator to the protected path lists.
  • README files: updated to document the auto-generated secret instead of the hardcoded default.

Invariant the fix relies on: The only forgeable authentication secret is the JWT signing secret; once that is random per install, no unauthenticated remote attacker can produce a credential the server will accept.

Code paths the fix explicitly covers: JWT token signing/verification (createDashboardAuthToken, verifyDashboardAuthToken, getDashboardAuthSession) and the middleware that gates /dashboard + protected API paths on verifyDashboardAuthToken.

What the fix does NOT cover:

  1. The default login password 123456src/app/api/auth/login/route.js still falls back to the hardcoded literal "123456" when no password hash is stored and INITIAL_PASSWORD is unset. This is a second hardcoded default auth credential in the same auth subsystem. The JWT fix does not touch it.
  2. The login route's lack of any remote/local gate on the default password (in v0.4.44 there is no isLocalRequest check at all on the default-password branch — only a tunnel-host block).
  3. (Later, in v0.4.80) a UI-only mustChangePassword hint that is not enforced by the middleware — see Variant section.

A codebase-wide search confirms the JWT-secret fix is otherwise complete: "9router-default-secret-change-me" no longer appears in any source file in the fixed/latest trees, and jose/jwtVerify for the dashboard session is centralized solely in dashboardSession.js. There is no second JWT-secret hardcode to bypass. The residual gap is the default password, a different credential on a different entry point but the same bug class.

Variant / Alternate Trigger

Type: Bypass of the fix (same root-cause class, different entry point).

Entry point: POST /api/auth/login with JSON body {"password":"123456"}, then reuse the issued auth_token cookie on GET /dashboard and protected /api/* endpoints. This is materially different from the original vector (forging the auth_token cookie offline with the known JWT secret — no server interaction needed).

Exact code path:

  • src/app/api/auth/login/route.jsPOST(request):
    • const storedHash = settings.password; (empty on fresh install)
    • const initialPassword = process.env.INITIAL_PASSWORD || "123456";
    • isValid = password === initialPassword;
    • on success: await setDashboardAuthCookie(cookieStore, request); → issues a genuinely valid auth_token JWT (now signed with the random per-install secret) and returns {"success":true}.
  • src/dashboardGuard.jsproxy(request) for /dashboard/:path* and protected /api/*: calls verifyDashboardAuthToken(token) → accepts the server-issued cookie → NextResponse.next() (200).

Bypass of the v0.4.80 "remote default-password guard": In v0.4.80 the login route adds:

const mustChangePassword = !storedHash && !process.env.INITIAL_PASSWORD && !isLocalRequest(request);
return NextResponse.json({ success: true, mustChangePassword });

mustChangePassword is consumed only by the browser UI (src/app/login/page.js line 78: if (data.mustChangePassword) {…}). The dashboard middleware (dashboardGuard.js) never reads mustChangePassword; it only verifies the JWT cookie, which was already issued. Therefore a remote attacker using the cookie directly (curl, a script, a bot) skips the forced password-change UI entirely. Confirmed: with a non-loopback Host header (simulated remote client), login returns {"success":true,"mustChangePassword":true} and a valid Set-Cookie: auth_token=…; using that cookie directly yields GET /dashboard → 200 and GET /api/keys → 200 {"keys":[]}.

Other candidates considered and ruled out (bounded search):

  • CLI token (x-9r-cli-token): derived from sha256(machineId + "9r-cli-auth")[0:16] via node-machine-id (host-specific), with a random-UUID fallback. Not a hardcoded constant → not forgeable remotely. Ruled out.

  • requireLogin === false: explicit operator setting, not a hardcoded default. Ruled out.

  • OIDC login: verifies against a remote JWKS, no local hardcoded secret. Ruled out.

  • <DATA_DIR>/jwt-secret local pre-create: requires local filesystem write access (different trust boundary). Ruled out as a network bypass.

  • Other hardcoded JWT-secret usages: none found after the fix (search-confirmed). Ruled out.

  • Package/component affected: 9router-app (the Next.js dashboard shipped by the 9router npm package / Docker image), specifically the password-login branch of src/app/api/auth/login/route.js and the dashboard auth middleware src/dashboardGuard.js.

  • Affected versions (as tested):

    • v0.4.41 (vulnerable / pre-JWT-fix): default-password login works → 200 on /dashboard and /api/keys.
    • v0.4.44 (JWT-fixed): default-password login still works → 200 on /dashboard and /api/keys (bypass of the JWT fix).
    • v0.4.80 (latest, with "remote default-password guard"): default-password login works for a simulated remote client; the issued cookie used directly → 200 on /dashboard and /api/keys (guard bypassed).
  • Precondition: a default/fresh install with no saved password hash (hasPassword:false, requireLogin:true) — i.e. the out-of-the-box state of npx 9router / docker run decolua/9router:latest before the operator sets a password. The login does not auto-save a hash, so the window persists until a manual password change.

  • Risk level: Critical. Complete unauthenticated remote authentication bypass → full dashboard UI + all protected API endpoints (/api/keys, /api/settings, /api/providers/*, /api/cli-tools/*, /api/mcp/*, /api/shutdown, etc.), exposing API keys / provider credentials / settings and allowing service shutdown.

Impact Parity

  • Disclosed/claimed maximum impact (parent CVE): unauthenticated remote authentication bypass → full dashboard + API access (authz_bypass, critical).
  • Reproduced impact from this variant run: full unauthenticated remote authentication bypass on the JWT-fixed (v0.4.44) and latest (v0.4.80) versions. GET /dashboard → 200 (25 KB dashboard HTML); GET /api/keys → 200 {"keys":[]}; negative control (no cookie) → 307 redirect to /login.
  • Parity: full — same impact class (auth bypass) and same end-state (full dashboard + protected-API access) as the parent CVE, achieved via a different hardcoded default credential on a different entry point.
  • Not demonstrated: code execution / RCE is not part of this CVE's claim and was not pursued; auth bypass was fully demonstrated.

Root Cause

The CVE-2026-49352 root cause is: the application uses a hardcoded, publicly-known default value as an authentication secret when the operator has not configured one, enabling unauthenticated remote auth bypass on a default install. The JWT signing secret was one instance of this pattern; the dashboard login password "123456" is a second instance of the same pattern, in a different credential (INITIAL_PASSWORD || "123456" vs JWT_SECRET || "9router-default-secret-change-me") on a different entry point (POST /api/auth/login vs forging the auth_token cookie).

The JWT fix (fe3ce25ae) only patched the JWT-secret instance. Because the authentication goal (prevent unauthenticated remote access) is enforced by the same middleware that accepts any valid auth_token, and because the login route will mint a valid auth_token for anyone who supplies the hardcoded default password, the same end-to-end bypass is reachable. The project itself later acknowledged this sibling issue: the v0.4.80 release notes list "Auth: real client IP rate-limiting + remote default-password guard", confirming the default password is treated as a security concern — but that later guard is UI-only and insufficient (demonstrated bypassable).

Fix commit (parent): fe3ce25ae3cda48c0702c2d452e17f6ec214009d ("Update JWT_SECRET handling", v0.4.44/v0.4.45). Later partial guard: v0.4.80 (515e2cc430…) — UI-only mustChangePassword, bypassed by direct cookie use.

Reproduction Steps

  1. Script: bundle/vuln_variant/reproduction_steps.sh (self-contained, portable, reuses the durable project cache).
  2. What the script does:
    • Reuses (or clones+builds) three 9router refs from the project cache: v0.4.41 (vulnerable), v0.4.44 (JWT-fixed), v0.4.80 (latest).
    • For each version, starts the real 9router Next.js server on a fresh, isolated DATA_DIR with JWT_SECRET intentionally unset, waits for health (/api/auth/status → 200, which also shows hasPassword:false, requireLogin:true).
    • Sends POST /api/auth/login with {"password":"123456"} (for v0.4.80, with a non-loopback Host header to simulate a remote client), captures the issued auth_token cookie.
    • Uses that cookie directly to GET /dashboard and GET /api/keys, and records a no-cookie negative control.
    • Writes bundle/vuln_variant/runtime_manifest.json and exits 0 iff the fixed version accepts the default-password cookie (200/200).
  3. Expected evidence of reproduction:
    • v0.4.41: login 200 + Set-Cookie: auth_token=…; /dashboard 200; /api/keys 200.
    • v0.4.44 (JWT-fixed): same — login 200 + cookie; /dashboard 200; /api/keys 200 = bypass.
    • v0.4.80 (latest, remote): login 200 + {"success":true,"mustChangePassword":true}
      • cookie; cookie used directly → /dashboard 200; /api/keys 200 = guard bypassed.
    • Negative control (no cookie): /dashboard → 307 redirect to /login.

Evidence

  • Run log: bundle/logs/vuln_variant/reproduction_steps.log — annotated three-version run; final summary shows 200/200 on all three and VARIANT CONFIRMED.
  • Server logs: bundle/logs/vuln_variant/{vuln_v0.4.41,fixed_v0.4.44,latest_v0.4.80}_server.log.
  • Exact tested commits: bundle/logs/vuln_variant/tested_commits.txt:
    • vulnerable v0.4.41 = cebc72e343dca5aad69b2828cb0d0f2e54b168d
    • fixed v0.4.44 = 9e87935c0e53f46d6ae04fbec656fc4d971547d7
    • latest v0.4.80 = 515e2cc4300ace55650ae366414cd51ef3d675df
  • HTTP artifacts: bundle/artifacts/http-variant/
    • fixed_login_hdr.txtHTTP/1.1 200 OK + set-cookie: auth_token=… for default-password login on the JWT-fixed build.
    • fixed_dash_hdr.txtHTTP/1.1 200 OK (dashboard served to default-pw cookie).
    • latest_login_hdr.txt200 + set-cookie: auth_token=… (remote client, cookie scoped to simulated remote host 203.0.113.55).
    • latest_dash_hdr.txtHTTP/1.1 200 OK; latest_api_resp.txt{"keys":[]}.
  • Key excerpts:
    FIXED  v0.4.44 default-pw         /dashboard: 200  /api/keys: 200   (expect 200/200 = BYPASS)
    LATEST v0.4.80 remote default-pw  /dashboard: 200  /api/keys: 200   (direct cookie, bypasses UI guard)
    === VARIANT CONFIRMED: default-password auth bypass survives the JWT fix (v0.4.44) ===
    
  • Environment: Node v24.18.0, npm 11.16.0, Next.js 16.2.10, jose 6.x, Linux x86_64; servers bound to 127.0.0.1:2014x; DATA_DIR isolated per build; JWT_SECRET intentionally unset; remote client simulated via non-loopback Host: 203.0.113.55:<port> header.

Recommendations / Next Steps

To ship a complete fix for "unauthenticated remote auth bypass via hardcoded defaults", extend the patch to cover the default-password instance:

  1. Remove the hardcoded "123456" default. On first run with no saved password, generate a random one-time bootstrap password (e.g. crypto.randomBytes(6).toString("base64")) and print it once to the server console/logs (and/or a first-run-password file in DATA_DIR, mode 0600) — never ship a constant password. Mirror the loadJwtSecret() approach.
  2. Enforce the default-password lock at the middleware, not the UI. If a default/initial password is still in use, the dashboard middleware (dashboardGuard.js) — not just login/page.js — must refuse to issue/accept a session for remote (non-loopback) clients until the password is changed. The current v0.4.80 mustChangePassword flag is bypassable because it is only read by the browser.
  3. Auto-rotate after first login (optional). Consider persisting a password hash on first successful default-password login so the default is consumed exactly once, narrowing the exposure window.
  4. Bind to loopback by default and require explicit opt-in (HOSTNAME=0.0.0.0 or a tunnel) for remote exposure, consistent with the existing isLocalRequest gating on spawn-capable routes.
  5. Document in the security/threat-model that no hardcoded default credentials (neither JWT secret nor login password) are acceptable for remote-exposed installs.

Additional Notes

  • Idempotency: bundle/vuln_variant/reproduction_steps.sh was executed three times; all runs exited 0 and produced identical verdicts/artifacts. It reuses the durable project cache (repos + builds) and isolates per-run DATA_DIRs, so repeated runs do not mutate state.
  • No repo checkout state was left changed. All testing used the pre-existing cached repos (repo, repo-fixed) plus a newly-cloned repo-latest under the project cache dir; no git checkout was performed on the repro's repos.
  • Scope caveat / honesty: The default password is a sibling instance of the same hardcoded-default-credential bug class rather than the literal JWT-secret mechanism. It is reported as a variant/bypass because (a) it is the same root-cause class, (b) it is a materially different entry point, (c) it crosses the same unauthenticated-remote-attacker → server trust boundary, (d) it survives the JWT fix, and (e) the project itself later added a (flawed) guard for it, corroborating that it is a real security concern rather than acceptable documented behavior. The narrower precondition (fresh install / no saved hash) vs. the parent CVE (any install without JWT_SECRET) is documented above.

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.