{"repro_id":"REPRO-2026-00217","version":7,"title":"9router hardcoded default fallback JWT secret allows authentication bypass","repro_type":"security","status":"published","severity":"critical","description":"9router npm package >=0.2.21 and <=0.4.41 uses the publicly known hardcoded string '9router-default-secret-change-me' as the fallback JWT secret when the JWT_SECRET environment variable is not set. Any unauthenticated remote attacker can forge a valid auth_token cookie and gain full access to the dashboard and API. Affected code is in src/app/api/auth/login/route.js and src/middleware.js (v0.2.21-v0.4.30) and src/lib/auth/dashboardSession.js (v0.4.31-v0.4.41). Fixed in 0.4.45. Target: run 9router without JWT_SECRET, sign a JWT with the known secret, set it as auth_token cookie, and access /dashboard.","root_cause":"# CVE-2026-49352 — 9router Hardcoded Default JWT Secret Authentication Bypass\n\n## Summary\n\n9router (npm package `9router`, GitHub `decolua/9router`) is a Next.js 16 web\napplication that serves an AI-router dashboard on port 20128. In versions\n>=0.2.21 and <=0.4.41, the JWT signing/verification secret is derived from\n`process.env.JWT_SECRET || \"9router-default-secret-change-me\"`. When an operator\nruns the app without setting the `JWT_SECRET` environment variable (the default\nout-of-the-box configuration), the application falls back to a publicly known,\nhardcoded string. Any unauthenticated remote attacker who knows this string can\nforge a valid HS256 JWT, set it as the `auth_token` cookie, and bypass\nauthentication entirely — accessing the dashboard and all protected API\nendpoints without credentials.\n\n## Impact\n\n- **Package/component affected:** `9router-app` (the Next.js dashboard\n  application shipped by the `9router` npm package / Docker image), specifically\n  the JWT session module `src/lib/auth/dashboardSession.js` (v0.4.31–v0.4.41)\n  and previously `src/app/api/auth/login/route.js` + `src/middleware.js`\n  (v0.2.21–v0.4.30).\n- **Affected versions:** >=0.2.21 and <=0.4.41 (verified against v0.4.41).\n- **Risk level:** Critical. Complete authentication bypass. An unauthenticated\n  remote attacker gains full access to the dashboard UI and every protected API\n  endpoint (`/api/keys`, `/api/settings/*`, `/api/providers/client`,\n  `/api/cli-tools/*`, `/api/mcp/*`, `/api/shutdown`, etc.), exposing API keys,\n  provider credentials, settings, and allowing shutdown of the service.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Authentication bypass — unauthenticated\n  remote attacker forges `auth_token` cookie and gains full access to dashboard\n  and API (`authz_bypass`, critical).\n- **Reproduced impact from this run:** Full authentication bypass demonstrated\n  against the real 9router Next.js server (v0.4.41) running without\n  `JWT_SECRET`. A forged HS256 JWT signed with the known secret produced:\n  - `GET /dashboard` → HTTP **200** (full 25 KB dashboard HTML served)\n  - `GET /api/keys` → HTTP **200**, body `{\"keys\":[]}` (protected API access)\n  - No-cookie controls correctly returned 307 (redirect to `/login`) and 401.\n- **Parity:** `full` — the claimed unauthenticated auth bypass was reproduced\n  end-to-end on the production HTTP path, and the fixed version (v0.4.44) was\n  shown to reject the identical forged token (negative control).\n- **Not demonstrated:** Not applicable; the claim is auth bypass (not code\n  execution), and auth bypass was fully demonstrated.\n\n## Root Cause\n\nThe JWT session module computes its HMAC secret at module-load time:\n\n```js\n// src/lib/auth/dashboardSession.js (v0.4.41)\nimport { SignJWT, jwtVerify } from \"jose\";\n\nconst SECRET = new TextEncoder().encode(\n  process.env.JWT_SECRET || \"9router-default-secret-change-me\"\n);\n```\n\nThe fallback literal `\"9router-default-secret-change-me\"` is a constant baked\ninto the published source. The dashboard middleware\n(`src/proxy.js` → `src/dashboardGuard.js`) protects `/dashboard/:path*` and\nsensitive API paths by calling `verifyDashboardAuthToken(token)`, which runs\n`jwtVerify(token, SECRET)` from the `jose` library. Because the fallback secret\nis identical on every installation that omits `JWT_SECRET`, an attacker can\nlocally reproduce the exact `SECRET` bytes and sign an arbitrary JWT that the\nserver will accept as genuine.\n\nThe login route (`src/app/api/auth/login/route.js`) issues tokens via\n`createDashboardAuthToken`, which signs `{ authenticated: true }` with HS256 and\na 24 h expiry. An attacker simply replicates this token offline — no password,\nno network interaction with the target is needed beyond submitting the cookie.\n\n**Fix commit:** `fe3ce25ae3cda48c0702c2d452e17f6ec214009d` (\"Update JWT_SECRET\nhandling\", released in v0.4.44/v0.4.45). The fix replaces the hardcoded\nfallback with `loadJwtSecret()`, which (1) uses `JWT_SECRET` if set, else (2)\nreads a persisted secret from `<DATA_DIR>/jwt-secret`, else (3) generates a\nrandom 32-byte hex secret via `crypto.randomBytes(32)` and writes it to disk\n(mode 0600). Each install therefore gets a unique, unguessable secret.\n\n## Reproduction Steps\n\n1. **Script:** `bundle/repro/reproduction_steps.sh` (self-contained, portable,\n   reuses the durable project cache at `<project_cache_dir>/repo` and\n   `<project_cache_dir>/repo-fixed`).\n2. **What the script does:**\n   - Checks out / reuses the vulnerable 9router v0.4.41 and the fixed v0.4.44\n     from the cached git mirror, building each with `npm run build`\n     (`next build --webpack`) if a build is not already present.\n   - Starts the **real** 9router Next.js production server\n     (`next start -p 20128 -H 127.0.0.1`) for the vulnerable version **without**\n     `JWT_SECRET` set, waits for it to become healthy (`/api/auth/status`\n     returns 200).\n   - Forges an HS256 JWT (via `bundle/repro/forge_jwt.py`) with payload\n     `{ \"authenticated\": true, \"iat\": <now>, \"exp\": <now+24h> }` signed with the\n     known secret `9router-default-secret-change-me`, and sends it as the\n     `auth_token` cookie to `GET /dashboard` and `GET /api/keys`.\n   - Repeats the same forged-cookie requests against the fixed v0.4.44 server\n     (also without `JWT_SECRET`) as a negative control.\n   - Writes `bundle/repro/runtime_manifest.json` and exits 0 only when the\n     vulnerable build accepts the forged token (200) **and** the fixed build\n     rejects it (307 / 401).\n3. **Expected evidence of reproduction:**\n   - Vulnerable: `GET /dashboard` with forged cookie → **HTTP 200** (dashboard\n     HTML, 25 268 bytes); `GET /api/keys` with forged cookie → **HTTP 200**\n     `{\"keys\":[]}`; no-cookie → 307 / 401.\n   - Fixed: `GET /dashboard` with forged cookie → **HTTP 307** redirect to\n     `/login`; `GET /api/keys` with forged cookie → **HTTP 401**\n     `{\"error\":\"Unauthorized\"}`.\n\n## Evidence\n\n- **Log files:**\n  - `bundle/logs/reproduction_steps.log` — full annotated run log.\n  - `bundle/logs/vuln_server.log` — vulnerable server startup\n    (`Next.js 16.2.10`, `Ready`, `[DB] Driver: better-sqlite3`).\n  - `bundle/logs/fixed_server.log` — fixed server startup.\n- **HTTP artifacts:**\n  - `bundle/artifacts/forged_jwt.txt` — the forged token.\n  - `bundle/artifacts/http/vuln_forged_hdr.txt` —\n    `HTTP/1.1 200 OK` (dashboard served to forged cookie on vulnerable build).\n  - `bundle/artifacts/http/vuln_forged_resp.html` — 25 268 bytes of dashboard\n    HTML (`<!DOCTYPE html>...`).\n  - `bundle/artifacts/http/vuln_api_forged_resp.txt` — `{\"keys\":[]}`.\n  - `bundle/artifacts/http/vuln_nocookie_hdr.txt` — `307` redirect to `/login`.\n  - `bundle/artifacts/http/fixed_forged_hdr.txt` —\n    `HTTP/1.1 307 Temporary Redirect`, `location: /login` (forged token\n    rejected by fixed build).\n  - `bundle/artifacts/http/fixed_api_forged_resp.txt` —\n    `{\"error\":\"Unauthorized\"}`.\n- **Key excerpt (vulnerable, forged cookie):**\n  ```\n  VULN /dashboard (forged auth_token)-> 200   (expect 200 = BYPASS)\n  VULN /api/keys (forged auth_token) -> 200   (expect 200 = API access)\n  ```\n- **Key excerpt (fixed negative control):**\n  ```\n  FIXED /dashboard (forged auth_token)-> 307 http://127.0.0.1:20128/login  (rejected)\n  FIXED /api/keys (forged auth_token) -> 401   (rejected)\n  ```\n- **Environment:** Node v24.18.0, npm 11.16.0, Next.js 16.2.10, jose 6.x,\n  Linux x86_64, server bound to `127.0.0.1:20128`, `DATA_DIR` isolated per\n  build, `JWT_SECRET` intentionally unset.\n\n## Recommendations / Next Steps\n\n- **Upgrade** to 9router >=0.4.45 (contains the fix). The fix generates a\n  random per-install secret when `JWT_SECRET` is unset.\n- **Set `JWT_SECRET`** to a long, random value via environment variable in all\n  deployments (Docker, systemd, npm global). Never rely on the fallback.\n- **Rotate** any `auth_token` cookies / API keys issued by deployments that ran\n  without `JWT_SECRET`, since they were effectively publicly forgeable.\n- **Restrict network exposure:** bind the dashboard to localhost or place it\n  behind authenticated reverse proxies; the app already has loopback/Origin\n  gating for some spawn-capable routes, but the dashboard itself was reachable.\n- **Testing recommendation:** add a regression test that asserts the app\n  refuses to start (or refuses to verify tokens) when no `JWT_SECRET` and no\n  persisted secret exist, and a test that a token signed with the old default\n  literal is rejected after upgrade.\n\n## Additional Notes\n\n- **Idempotency:** The script was run twice consecutively; both runs produced\n  identical results (vulnerable 200/200, fixed 307/401) and exited 0. Builds\n  are cached in the durable project cache and reused on subsequent runs.\n- **Negative control:** The fixed v0.4.44 build (commit `fe3ce25ae`) was\n  compiled and run under identical conditions (no `JWT_SECRET`, same forged\n  token). Its middleware contains no occurrence of the hardcoded literal\n  (verified via grep of `.next/server/middleware.js`), and it rejected the\n  forged token, confirming the fix is effective and that the bypass is specific\n  to the hardcoded-secret versions.\n- **Library-level cross-check:** The forged token was independently verified\n  with the real `jose` library (`jwtVerify` → OK with the known secret; →\n  \"signature verification failed\" with a random secret) before the HTTP proof,\n  matching the exact verification path used by `verifyDashboardAuthToken`.\n- **Scope note:** `next start` prints a warning under `output: standalone`\n  recommending `node .next/standalone/server.js`; this is cosmetic — `next start`\n  correctly serves the built app and runs the proxy/middleware, as evidenced by\n  the 200/307/401 responses.\n","cve_id":"CVE-2026-49352","cwe_id":"CWE-798 (Use of Hard-coded Credentials)","source_url":"https://github.com/decolua/9router","package":{"name":"decolua/9router","ecosystem":"github","affected_versions":">=0.2.21 <=0.4.41","fixed_version":"0.4.45"},"reproduced_at":"2026-07-03T15:50:05.535886+00:00","duration_secs":830.0,"tool_calls":196,"handoffs":2,"total_cost_usd":2.58873253,"agent_costs":{"hypothesis_generator":0.0103442,"judge":0.00893555,"repro":1.4125896,"support":0.06676719,"vuln_variant":1.0900959900000002},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/glm-5p2":0.0103442},"judge":{"gpt-5.4-mini":0.00893555},"repro":{"accounts/fireworks/routers/glm-5p2-fast":1.4125896},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.06676719},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":1.0900959900000002}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-03T15:50:06.353198+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":12321,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":9770,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":13289,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":16685,"category":"analysis"},{"path":"bundle/ticket.md","filename":"ticket.md","size":799,"category":"ticket"},{"path":"bundle/ticket.json","filename":"ticket.json","size":1758,"category":"other"},{"path":"bundle/repro/forge_jwt.py","filename":"forge_jwt.py","size":1143,"category":"script"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":821,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1490,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":2748,"category":"log"},{"path":"bundle/logs/vuln_server.log","filename":"vuln_server.log","size":402,"category":"log"},{"path":"bundle/logs/fixed_server.log","filename":"fixed_server.log","size":403,"category":"log"},{"path":"bundle/logs/vuln_variant/fixed_v0.4.44_server.log","filename":"fixed_v0.4.44_server.log","size":403,"category":"log"},{"path":"bundle/logs/vuln_variant/vuln_v0.4.41_server.log","filename":"vuln_v0.4.41_server.log","size":403,"category":"log"},{"path":"bundle/logs/vuln_variant/latest_v0.4.80_server.log","filename":"latest_v0.4.80_server.log","size":403,"category":"log"},{"path":"bundle/logs/vuln_variant/latest_remote_dashboard_hdr.txt","filename":"latest_remote_dashboard_hdr.txt","size":444,"category":"other"},{"path":"bundle/logs/vuln_variant/latest_remote_apikeys_resp.txt","filename":"latest_remote_apikeys_resp.txt","size":11,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_login_defaultpw_hdr.txt","filename":"fixed_login_defaultpw_hdr.txt","size":463,"category":"other"},{"path":"bundle/logs/vuln_variant/latest_remote_cookies.txt","filename":"latest_remote_cookies.txt","size":322,"category":"other"},{"path":"bundle/logs/vuln_variant/tested_commits.txt","filename":"tested_commits.txt","size":171,"category":"other"},{"path":"bundle/logs/vuln_variant/reproduction_steps.log","filename":"reproduction_steps.log","size":2885,"category":"log"},{"path":"bundle/logs/vuln_variant/vuln_server.log","filename":"vuln_server.log","size":410,"category":"log"},{"path":"bundle/logs/vuln_variant/fixed_server.log","filename":"fixed_server.log","size":411,"category":"log"},{"path":"bundle/logs/vuln_variant/latest_server.log","filename":"latest_server.log","size":412,"category":"log"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1885,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":6966,"category":"documentation"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":4797,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":2589,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":2164,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":3312,"category":"other"}]}