# CVE-2026-49352 — Variant / Bypass RCA: Hardcoded Default Login Password Authentication Bypass

## Summary

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:

```js
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 `123456`** — `src/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.js` → `POST(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.js` → `proxy(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:
```js
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.

## Impact

- **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.txt` — `HTTP/1.1 200 OK` + `set-cookie: auth_token=…` for
    default-password login on the JWT-fixed build.
  - `fixed_dash_hdr.txt` — `HTTP/1.1 200 OK` (dashboard served to default-pw cookie).
  - `latest_login_hdr.txt` — `200` + `set-cookie: auth_token=…` (remote client,
    cookie scoped to simulated remote host `203.0.113.55`).
  - `latest_dash_hdr.txt` — `HTTP/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_DIR`s, 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.
