CVE-2026-49352: Verified Repro With Script Download
CVE-2026-49352: 9router hardcoded default fallback JWT secret allows authentication bypass
CVE-2026-49352 is verified against decolua/9router · github. Affected versions: >=0.2.21 <=0.4.41. Fixed in 0.4.45. Vulnerability class: Auth Bypass. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00217.
What Is CVE-2026-49352?
CVE-2026-49352 is a critical authentication bypass caused by hard-coded credentials (CWE-798) in the 9router npm package (decolua/9router). When the JWT_SECRET environment variable is unset, 9router uses a publicly known default secret, letting an unauthenticated attacker forge a valid session. Pruva reproduced it (reproduction REPRO-2026-00217).
CVE-2026-49352 Severity & CVSS Score
CVE-2026-49352 is rated critical severity, with a CVSS base score of 9.8 out of 10.
Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
Affected decolua/9router Versions
decolua/9router · github versions >=0.2.21 <=0.4.41 are affected.
How to Reproduce CVE-2026-49352
pruva-verify REPRO-2026-00217 curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00217/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-49352
- reached the target end-to-end
- full exploit chain demonstrated
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
auth_token cookie containing HS256 JWT signed with hardcoded secret '9router-default-secret-change-me'
- GET /dashboard and GET /api/keys with forged auth_token cookie against real 9router Next.js server (v0.4.41) running WITHOUT JWT_SECRET env var
reproduction_steps.sh Bypass of the CVE-2026-49352 JWT-secret fix via a sibling hardcoded default auth credential: the dashboard login password '123456' (INITIAL_PASSWORD fallback) in src/app/api/auth/login/route.js. On a fresh install (no saved password hash) an unauthenticated remote attacker POSTs {"password":"123456"} to /api/auth/logi…
How the agent worked
Root Cause and Exploit Chain for CVE-2026-49352
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 theJWT_SECRETenvironment 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 theauth_tokencookie, and bypass authentication entirely — accessing the dashboard and all protected API endpoints without credentials.
- Package/component affected:
9router-app(the Next.js dashboard application shipped by the9routernpm package / Docker image), specifically the JWT session modulesrc/lib/auth/dashboardSession.js(v0.4.31–v0.4.41) and previouslysrc/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_tokencookie 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:
// 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
- Script:
bundle/repro/reproduction_steps.sh(self-contained, portable, reuses the durable project cache at<project_cache_dir>/repoand<project_cache_dir>/repo-fixed). - 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 withoutJWT_SECRETset, waits for it to become healthy (/api/auth/statusreturns 200). - Forges an HS256 JWT (via
bundle/repro/forge_jwt.py) with payload{ "authenticated": true, "iat": <now>, "exp": <now+24h> }signed with the known secret9router-default-secret-change-me, and sends it as theauth_tokencookie toGET /dashboardandGET /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.jsonand exits 0 only when the vulnerable build accepts the forged token (200) and the fixed build rejects it (307 / 401).
- Checks out / reuses the vulnerable 9router v0.4.41 and the fixed v0.4.44
from the cached git mirror, building each with
- Expected evidence of reproduction:
- Vulnerable:
GET /dashboardwith forged cookie → HTTP 200 (dashboard HTML, 25 268 bytes);GET /api/keyswith forged cookie → HTTP 200{"keys":[]}; no-cookie → 307 / 401. - Fixed:
GET /dashboardwith forged cookie → HTTP 307 redirect to/login;GET /api/keyswith forged cookie → HTTP 401{"error":"Unauthorized"}.
- Vulnerable:
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—307redirect 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_DIRisolated per build,JWT_SECRETintentionally unset.
Recommendations / Next Steps
- Upgrade to 9router >=0.4.45 (contains the fix). The fix generates a
random per-install secret when
JWT_SECRETis unset. - Set
JWT_SECRETto a long, random value via environment variable in all deployments (Docker, systemd, npm global). Never rely on the fallback. - Rotate any
auth_tokencookies / API keys issued by deployments that ran withoutJWT_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_SECRETand 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 (noJWT_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
joselibrary (jwtVerify→ OK with the known secret; → "signature verification failed" with a random secret) before the HTTP proof, matching the exact verification path used byverifyDashboardAuthToken. - Scope note:
next startprints a warning underoutput: standalonerecommendingnode .next/standalone/server.js; this is cosmetic —next startcorrectly serves the built app and runs the proxy/middleware, as evidenced by the 200/307/401 responses.
Variant Analysis & Alternative Triggers for CVE-2026-49352
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:
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: replacesconst SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "9router-default-secret-change-me")withloadJwtSecret(), which (1) usesJWT_SECRETif set, else (2) reads a persisted secret from<DATA_DIR>/jwt-secret, else (3) generates a random 32-byte hex secret viacrypto.randomBytes(32)and persists it (mode 0600).src/dashboardGuard.js/src/proxy.js: add/api/translatorto 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:
- The default login password
123456—src/app/api/auth/login/route.jsstill falls back to the hardcoded literal"123456"when no password hash is stored andINITIAL_PASSWORDis unset. This is a second hardcoded default auth credential in the same auth subsystem. The JWT fix does not touch it. - The login route's lack of any remote/local gate on the default password
(in v0.4.44 there is no
isLocalRequestcheck at all on the default-password branch — only a tunnel-host block). - (Later, in v0.4.80) a UI-only
mustChangePasswordhint 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 validauth_tokenJWT (now signed with the random per-install secret) and returns{"success":true}.
src/dashboardGuard.js→proxy(request)for/dashboard/:path*and protected/api/*: callsverifyDashboardAuthToken(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:
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 fromsha256(machineId + "9r-cli-auth")[0:16]vianode-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-secretlocal 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.
Package/component affected:
9router-app(the Next.js dashboard shipped by the9routernpm package / Docker image), specifically the password-login branch ofsrc/app/api/auth/login/route.jsand the dashboard auth middlewaresrc/dashboardGuard.js.Affected versions (as tested):
- v0.4.41 (vulnerable / pre-JWT-fix): default-password login works → 200 on
/dashboardand/api/keys. - v0.4.44 (JWT-fixed): default-password login still works → 200 on
/dashboardand/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
/dashboardand/api/keys(guard bypassed).
- v0.4.41 (vulnerable / pre-JWT-fix): default-password login works → 200 on
Precondition: a default/fresh install with no saved password hash (
hasPassword:false,requireLogin:true) — i.e. the out-of-the-box state ofnpx 9router/docker run decolua/9router:latestbefore 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
- Script:
bundle/vuln_variant/reproduction_steps.sh(self-contained, portable, reuses the durable project cache). - 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_DIRwithJWT_SECRETintentionally unset, waits for health (/api/auth/status→ 200, which also showshasPassword:false,requireLogin:true). - Sends
POST /api/auth/loginwith{"password":"123456"}(for v0.4.80, with a non-loopbackHostheader to simulate a remote client), captures the issuedauth_tokencookie. - Uses that cookie directly to
GET /dashboardandGET /api/keys, and records a no-cookie negative control. - Writes
bundle/vuln_variant/runtime_manifest.jsonand exits 0 iff the fixed version accepts the default-password cookie (200/200).
- Expected evidence of reproduction:
- v0.4.41: login 200 +
Set-Cookie: auth_token=…;/dashboard200;/api/keys200. - v0.4.44 (JWT-fixed): same — login 200 + cookie;
/dashboard200;/api/keys200 = bypass. - v0.4.80 (latest, remote): login 200 +
{"success":true,"mustChangePassword":true}- cookie; cookie used directly →
/dashboard200;/api/keys200 = guard bypassed.
- cookie; cookie used directly →
- Negative control (no cookie):
/dashboard→ 307 redirect to/login.
- v0.4.41: login 200 +
Evidence
- Run log:
bundle/logs/vuln_variant/reproduction_steps.log— annotated three-version run; final summary shows 200/200 on all three andVARIANT 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
- vulnerable v0.4.41 =
- 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 host203.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_DIRisolated per build;JWT_SECRETintentionally unset; remote client simulated via non-loopbackHost: 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:
- 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 afirst-run-passwordfile inDATA_DIR, mode 0600) — never ship a constant password. Mirror theloadJwtSecret()approach. - 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 justlogin/page.js— must refuse to issue/accept a session for remote (non-loopback) clients until the password is changed. The current v0.4.80mustChangePasswordflag is bypassable because it is only read by the browser. - 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.
- Bind to loopback by default and require explicit opt-in (
HOSTNAME=0.0.0.0or a tunnel) for remote exposure, consistent with the existingisLocalRequestgating on spawn-capable routes. - 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.shwas executed three times; all runs exited 0 and produced identical verdicts/artifacts. It reuses the durable project cache (repos + builds) and isolates per-runDATA_DIRs, 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-clonedrepo-latestunder the project cache dir; nogit checkoutwas 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.
CVE-2026-49352 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
ls -la /data/pruva/project-cache/4363a60d-57f6-4170-9aa0-2e4f54c86001/ 2>/dev/null; echo "---repo-mirrors---"; ls -la /data/pruva/project-cache/4363a60d-57f6-4170-9aa0-2e4f54c86001/repo-mirrors/ 2>/dev/null; echo "---done---"total 0 drwxr-xr-x 1 vscode vscode 24 Jul 3 14:29 . drwxr-xr-x 1 vscode vscode 2254 Jul 3 14:29 .. drwxr-xr-x 1 vscode vscode 0 Jul 3 14:29 repo-mirrors ---repo-mirrors--- total 0 drwxr-xr-x 1 vscode vscode 0 Jul 3 14:29 . drwxr-xr-x 1 vscode vscode 24 Jul 3 14:29 ..
npm view 9router versions --json 2>/dev/null | head -100; echo "===LATEST==="; npm view 9router version 2>/dev/null; echo "===DIST==="; npm view 9router dist.tarball 2>/dev/null{"cwd":"/data/pruva/runs/67f28c86-fec1-4100-b783-c29256359392","duration_ms":1003,"exit_code":0,"stderr":"","stdout":"[\n \"0.2.13\",\n \"0.2.14\",\n \"0.2.15\",\n \"0.2.16\",\n \"0.2.17\",\n \"0.2.18\",\n \"0.2.19\",\n \"0.2.20\",\n \"0.2.21\",\n \"0.2.22\",\n \"0.2.23\",\n \"0.2.24\",\n \"0.2.25\",\n \"0.2.26\",\n \"0.2.27\",\n \"0.2.29\",\n \"0.2.30\",\n \"0.2.31\",\n \"0.2.32\",\n \"0.2.33\",\n \"0.2.34\",\n \"0.2.36\",\n \"0.2.37\",\n \"0.2.38\",\n \"0.2.39\",\n \"0.2.40\",\n \"0.2.41\",\n \"0.2.42\",\n \"0.2.43\",\n \"0.2.44\",\n \"0.2.45\",\n \"0.2.46\",\n \"0.2.47\",\n \"0.2.48\",\n \"0.2.49\",\n \"0.2.50\",\n \"0.2.51\",\n \"0.2.52\",\n \"0.2.53\",\n \"0.2.54\",\n \"0.2.56\",\n \"0.2.57\",\n \"0.2.58\",\n \"0.2.59\",\n \"0.2.60\",\n \"0.2.61\",\n \"0.2.62\",\n \"0.2.63\",\n \"0.2.64\",\n \"0.2.65\",\n \"0.2.66\",\n \"0.2.67\",\n \"0.2.68\",\n \"0.2.70\",\n \"0.2.71\",\n \"0.2.72\",\n \"0.2.73\",\n \"0.2.74\",\n \"0.2.75\",\n \"0.2.76\",\n \"0.2.77\",\n \"0.2.78\",\n \"0.2.79\",\n \"0.2.80\",\n \"0.2.81\",\n \"0.2.83\",\n \"0.2.84\",\n \"0.2.87\",\n \"0.2.88\",\n \"0.2.89\",\n \"0.2.90\",\n \"0.2.91\",\… [truncated]cd /data/pruva/project-cache/4363a60d-57f6-4170-9aa0-2e4f54c86001/repo-mirrors && git clone --mirror https://github.com/decolua/9router.git 9router.git 2>&1 | tail -20; echo "EXIT=$?"Cloning into bare repository '9router.git'... EXIT=0
Artifacts and Evidence for CVE-2026-49352
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-49352
Upgrade decolua/9router · github to 0.4.45 or later.
FAQ: CVE-2026-49352
How does an attacker exploit CVE-2026-49352?
Which versions of 9router are affected by CVE-2026-49352, and where is it fixed?
How can I reproduce CVE-2026-49352?
References for CVE-2026-49352
Authoritative sources for CVE-2026-49352 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.