# 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 `<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.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` — `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.
