# Patch Analysis — CVE-2026-49352 (9router hardcoded JWT secret)

## Target threat model

There is **no `SECURITY.md`** or formal threat-model document in the 9router
repository (`decolua/9router`). The project's security posture is inferable from
the code: the dashboard is a local-first AI-router admin UI (default port 20128)
that is expected to be exposed to a network (Docker `-p 20128:20128`, tunnels,
Tailscale). The middleware (`src/dashboardGuard.js`) explicitly defends against
*unauthenticated remote access*, *tunnel/LAN access*, *CSRF* (same-host/Origin
checks), and treats *hardcoded default auth secrets* as vulnerabilities — as
evidenced by the CVE-2026-49352 fix itself and by the later v0.4.80
*"remote default-password guard"*. There is no documentation stating that default
credentials are acceptable; on the contrary, the README historically flagged the
JWT default with "**change in production**", and that default was still treated as
a CVE. Default credentials (CWE-798) are therefore in-scope.

## What the fix changes

**Commit:** `fe3ce25ae3cda48c0702c2d452e17f6ec214009d` ("Update JWT_SECRET
handling"), released in v0.4.44/v0.4.45.

Files touched:
- `src/lib/auth/dashboardSession.js` (core change)
- `src/dashboardGuard.js`, `src/proxy.js` (add `/api/translator` to protected lists)
- `README.md` + 4 i18n READMEs (doc update: default secret → "Auto-generated")

Core logic change (`dashboardSession.js`):

Before:
```js
import { SignJWT, jwtVerify } from "jose";
const SECRET = new TextEncoder().encode(
  process.env.JWT_SECRET || "9router-default-secret-change-me"
);
```

After:
```js
import { SignJWT, jwtVerify } from "jose";
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { DATA_DIR } from "@/lib/dataDir";

function loadJwtSecret() {
  if (process.env.JWT_SECRET) return process.env.JWT_SECRET;
  const file = path.join(DATA_DIR, "jwt-secret");
  try { return fs.readFileSync(file, "utf8").trim(); } catch {}
  fs.mkdirSync(DATA_DIR, { recursive: true });
  const generated = crypto.randomBytes(32).toString("hex");
  fs.writeFileSync(file, generated, { mode: 0o600 });
  return generated;
}
const SECRET = new TextEncoder().encode(loadJwtSecret());
```

Behavior change: when `JWT_SECRET` is unset, the secret is no longer a public
constant; it is a random 32-byte hex value generated once per install and
persisted at `<DATA_DIR>/jwt-secret` (mode 0600). A forged JWT signed with the
old literal is now rejected (verified: fixed build returns 307/401 for the
forged cookie).

## Fix assumptions

1. **Single forgeable secret.** The fix assumes the *only* hardcoded default
   authentication credential is the JWT signing secret. Once it is random per
   install, no unauthenticated remote attacker can mint an accepted credential.
2. **Centralized JWT usage.** The fix assumes all dashboard auth-token
   signing/verification flows through `dashboardSession.js`
   (`createDashboardAuthToken` / `verifyDashboardAuthToken` /
   `getDashboardAuthSession`). This assumption **holds**: a codebase search finds
   no other `jose`/`jwtVerify` usage for the dashboard session (the only other
   `jose` usage is `src/lib/auth/oidc.js`, which verifies against a *remote*
   JWKS, and `open-sse/services/tokenRefresh.js`, which is outbound Vertex AI
   token minting). The string `9router-default-secret-change-me` is fully removed
   from all source files.
3. **`DATA_DIR` is host-private.** `loadJwtSecret()` reads/writes
   `<DATA_DIR>/jwt-secret`; the fix assumes an attacker cannot pre-create/replace
   that file. That holds for the *network* trust boundary (local fs write is a
   different boundary).

## Code paths the fix does NOT cover

1. **Default login password `"123456"`** — `src/app/api/auth/login/route.js`:
   ```js
   const initialPassword = process.env.INITIAL_PASSWORD || "123456";
   isValid = password === initialPassword;   // when no storedHash
   ```
   This is a **second hardcoded default auth credential** in the same auth
   subsystem, on a different entry point (`POST /api/auth/login`). The JWT fix
   does not touch it. The login route mints a *genuinely valid* `auth_token`
   (signed with the new random secret) for anyone supplying `"123456"` on a
   fresh install, after which the middleware accepts the cookie. → **Bypass
   confirmed on v0.4.44 and v0.4.80.**

2. **No remote/local gate on the default password (v0.4.44).** The v0.4.44 login
   route has only a tunnel-host block; there is no `isLocalRequest` check, so a
   remote client reaching the dashboard port directly can use the default
   password.

3. **v0.4.80 "remote default-password guard" is UI-only.** The later guard:
   ```js
   const mustChangePassword = !storedHash && !process.env.INITIAL_PASSWORD && !isLocalRequest(request);
   return NextResponse.json({ success: true, mustChangePassword });
   ```
   sets `mustChangePassword` only as a JSON hint consumed by
   `src/app/login/page.js`. The dashboard middleware (`dashboardGuard.js`) never
   reads it and still accepts the issued `auth_token`. A non-browser client using
   the cookie directly bypasses the forced password-change UI. → **Guard
   bypassed (confirmed on v0.4.80).**

## Behavior before vs after the fix

| Request | v0.4.41 (vuln) | v0.4.44 (JWT-fixed) | v0.4.80 (latest) |
|---|---|---|---|
| Forged `auth_token` (old JWT secret) → `/dashboard` | **200** (bypass) | 307 (rejected) | 307 (rejected) |
| Forged `auth_token` → `/api/keys` | **200** | 401 (rejected) | 401 (rejected) |
| `POST /api/auth/login` `{"password":"123456"}` (fresh install) | 200 + cookie | 200 + cookie | 200 + cookie (`mustChangePassword:true` if remote) |
| Default-pw cookie → `/dashboard` | **200** | **200** (bypass) | **200** (remote, direct cookie — guard bypassed) |
| Default-pw cookie → `/api/keys` | **200** | **200** (bypass) | **200** (remote, direct cookie — guard bypassed) |
| No cookie → `/dashboard` (control) | 307 | 307 | 307 |

The JWT-secret vector is fixed (rows 1–2). The default-password vector is **not**
fixed and remains a complete unauthenticated remote auth bypass on the fixed and
latest versions (rows 3–5).

## Completeness assessment

- **JWT-secret instance:** COMPLETE — the hardcoded fallback is removed and
  centralized; no residual JWT-secret hardcode exists.
- **Hardcoded-default-credential class (the actual security goal):** INCOMPLETE —
  the sibling default-password instance remains and is remotely exploitable on
  the fixed and latest versions; the latest version's mitigating guard is
  ineffective (UI-only).

## Recommendations for a complete fix

See `bundle/vuln_variant/rca_report.md` → *Recommendations / Next Steps*: remove
the `"123456"` constant (random bootstrap password printed once), enforce the
default-password lock in the middleware (not the UI) for remote clients, and
optionally consume the default password on first login.
