# REPRO-2026-00217: 9router hardcoded default fallback JWT secret allows authentication bypass ## Summary Status: published Severity: critical Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00217 CVE: CVE-2026-49352 ## Package Name: decolua/9router Ecosystem: github Affected: >=0.2.21 <=0.4.41 Fixed: 0.4.45 ## Root Cause # CVE-2026-49352 — 9router Hardcoded Default JWT Secret Authentication Bypass ## Summary 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. ## Impact - **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: ```js // 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 `/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 `/repo` and `/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": , "exp": }` 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.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 (`...`). - `bundle/artifacts/http/vuln_api_forged_resp.txt` — `{"keys":[]}`. - `bundle/artifacts/http/vuln_nocookie_hdr.txt` — `307` redirect 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_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. ## Reproduction Details Reproduced: 2026-07-03T15:50:05.535Z Duration: 830 seconds Tool calls: 196 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00217 pruva-verify CVE-2026-49352 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00217&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00217/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-49352 - Source: https://github.com/decolua/9router ## Artifacts - bundle/repro/reproduction_steps.sh (reproduction_script, 12321 bytes) - bundle/repro/rca_report.md (analysis, 9770 bytes) - bundle/vuln_variant/reproduction_steps.sh (reproduction_script, 13289 bytes) - bundle/vuln_variant/rca_report.md (analysis, 16685 bytes) - bundle/ticket.md (ticket, 799 bytes) - bundle/ticket.json (other, 1758 bytes) - bundle/repro/forge_jwt.py (script, 1143 bytes) - bundle/repro/validation_verdict.json (other, 821 bytes) - bundle/repro/runtime_manifest.json (other, 1490 bytes) - bundle/logs/reproduction_steps.log (log, 2748 bytes) - bundle/logs/vuln_server.log (log, 402 bytes) - bundle/logs/fixed_server.log (log, 403 bytes) - bundle/logs/vuln_variant/fixed_v0.4.44_server.log (log, 403 bytes) - bundle/logs/vuln_variant/vuln_v0.4.41_server.log (log, 403 bytes) - bundle/logs/vuln_variant/latest_v0.4.80_server.log (log, 403 bytes) - bundle/logs/vuln_variant/latest_remote_dashboard_hdr.txt (other, 444 bytes) - bundle/logs/vuln_variant/latest_remote_apikeys_resp.txt (other, 11 bytes) - bundle/logs/vuln_variant/fixed_login_defaultpw_hdr.txt (other, 463 bytes) - bundle/logs/vuln_variant/latest_remote_cookies.txt (other, 322 bytes) - bundle/logs/vuln_variant/tested_commits.txt (other, 171 bytes) - bundle/logs/vuln_variant/reproduction_steps.log (log, 2885 bytes) - bundle/logs/vuln_variant/vuln_server.log (log, 410 bytes) - bundle/logs/vuln_variant/fixed_server.log (log, 411 bytes) - bundle/logs/vuln_variant/latest_server.log (log, 412 bytes) - bundle/vuln_variant/runtime_manifest.json (other, 1885 bytes) - bundle/vuln_variant/patch_analysis.md (documentation, 6966 bytes) - bundle/vuln_variant/variant_manifest.json (other, 4797 bytes) - bundle/vuln_variant/validation_verdict.json (other, 2589 bytes) - bundle/vuln_variant/source_identity.json (other, 2164 bytes) - bundle/vuln_variant/root_cause_equivalence.json (other, 3312 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00217 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00217/artifacts/bundle/repro/reproduction_steps.sh - Web: https://pruva.dev/r/REPRO-2026-00217 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://pruva.dev