GHSA-5c25-7vpj-9mqh: Nezha Monitoring: Pre-auth path traversal via /dashboard.. prefix confusion leaks jwt_secret_key
Summary`fallbackToFrontend` in the dashboard's `NoRoute` handler treats any URL whose **raw string** starts with `/dashboard` as an admin-frontend asset request. The check uses `strings.HasPrefix`, not a path-segment match, so the input `/dashboard../data/config.yaml` is accepted; `strings.TrimPrefix` leaves `../data/config.yaml`; and `path.Join("admin-dist", "../data/config.yaml")` normalizes to `data/config.yaml` — which `os.Stat` finds and `http.ServeFile` returns. No authentication required.In default deployments (the values shipped in `model/config.go` and the layout shipped in the project `Dockerfile`) `data/config.yaml` contains the HS256 `jwt_secret_key` used by `cmd/dashboard/controller/jwt.go` to sign every dashboard session cookie. A unauth attacker reads that secret, forges an admin JWT, and signs in as any user — full dashboard takeover from one GET request.DetailsRoot cause`fallbackToFrontend` is wired as the catch-all at `cmd/dashboard/controller/controller.go:157` — `r.NoRoute(fallbackToFrontend(frontendDist))` — so every URL not matched by an earlier route reaches it, including pre-auth.Path math (verified, see appendix)| Input `URL.Path` | `TrimPrefix(..., "/dashboard")` | `path.Join("admin-dist", ...)` | Reachable file |
|---|---|---|---|
| `/dashboard/login` | `/login` | `admin-dist/login` | legitimate, intended |
| `/dashboard/../data/config.yaml` | `/../data/config.yaml` | `data/config.yaml` | **but blocked by Go `http.ServeFile`'s URL `..`-segment guard → 400** |
| `/dashboard../data/config.yaml` | `../data/config.yaml` | `data/config.yaml` | **served, 200** |
| `/dashboard%2e%2e/data/config.yaml` | `../data/config.yaml` (decoded) | `data/config.yaml` | **served, 200** |
| `/dashboard..%2fdata/config.yaml` | `../data/config.yaml` (decoded) | `data/config.yaml` | **served, 200** |The negative control (`/dashboard/../data/config.yaml`) lands at the same on-disk path after `path.Join`, but is rejected by `http.ServeFile` because Go's stdlib enforces a URL-level traversal guard that fires when the **request URL** itself contains a standalone `..` segment. The bypass works because in `/dashboard../...` the first URL segment is the single token `dashboard..` — no standalone `..` — so the stdlib guard does not trigger. The traversal segment is **created after `TrimPrefix`**, downstream of every defense.Why the existing defenses missThe prefix check is a substring test on the raw URL string, not a segment test. `dashboard` and `dashboard..` are both accepted.`path.Join` silently `Clean`s the result — so the `..` is consumed correctly to escape `admin-dist`, with no error returned to indicate escape.Go's `http.ServeFile` stdlib guard fires only on URLs with a standalone `..` segment (per `net/http.containsDotDot`). The payload puts the dots inside the first segment instead.No anchored "is this still under the template root?" check exists after `path.Join`.PoCSetupThe harness plants this `data/config.yaml`:Observed responses**Primary payload — pre-auth secret disclosure:****Negative control — Go stdlib guard rejects the canonical form:****Encoded-dot variant — bypass also works:****Encoded-slash variant — bypass also works:****Double-encoded — confirms the bypass requires single-level encoding:**The literal `%252e%252e` does not decode to `..`, so the path becomes `admin-dist/%2e%2e/data/config.yaml` (no escape), `os.Stat` fails, and the handler falls through to serving `admin-dist/index.html` — no secret disclosure.**Encoded leading slash — also blocked at the stdlib layer:****SQLite database exfil — same primitive:**Sanity checksNormal `/dashboard/` request still serves `admin-dist/index.html` with HTTP 200 — the bypass does not regress legitimate behavior.Requests to `/api/...` still hit the JSON-404 branch — the bypass is isolated to the `/dashboard` fallback.ImpactDirect primitiveUnauth read of any file in the dashboard's working directory subtree reachable by escaping `admin-dist` one level. In default deployments that includes:| File | Default path | Why it matters |
|---|---|---|
| `data/config.yaml` | from `-c` flag default (`cmd/dashboard/main.go:104`) | Contains `jwt_secret_key` (signing key, **HS256**), `agent_secret_key`, OAuth2 client secrets, GitHub release token, GeoIP API key, and any custom secrets |
| `data/sqlite.db` | from `-db` flag default (`cmd/dashboard/main.go:105`) | Full dashboard state: users (incl. admin), bcrypt password hashes, server registry, API tokens, notification configs |Chain to administrative account takeover (verified path)**Read config** — `GET /dashboard../data/config.yaml` returns plaintext YAML containing `jwt_secret_key`.**Read database** — `GET /dashboard../data/sqlite.db` returns the SQLite file; an attacker opens it and reads the `users` table to recover admin user IDs (and any other claims the JWT references).**Forge a JWT** — the dashboard's JWT middleware at `cmd/dashboard/controller/jwt.go:22,27` is wired with:HS256 is symmetric — possession of the key is sufficient to sign tokens that pass verification. An attacker mints a token whose `user_id` claim matches the admin user from step 2 and attaches it as the `nz-jwt` cookie (or `Authorization: Bearer ...`).**Operate as admin** — every admin handler (`adminHandler` chain) now accepts the forged session, granting CRUD on servers, users, cron tasks, notifications, and OAuth2 settings.
The chain is fully deterministic against a default-configured dashboard: two unauth HTTP GETs and a JWT signing operation, no race, no user interaction, no special timing.Suggested fixMake the prefix test segment-aware and reject paths whose cleaned form escapes the template root **before** any filesystem call. Minimal diff:The `/dashboard` -> `/dashboard/` redirect at line 382 already exists, so requiring the trailing slash is safe and aligns with the regexes in `frontendPageUrlRegistry`.The same hardening should be applied to the user-template branch (lines 399–405), which uses the same `path.Join` pattern with `singleton.Conf.UserTemplate`. While the `/dashboard` prefix-confusion vector doesn't hit it directly, any future code change that hands a controlled `URL.Path` to that branch would re-introduce the same primitive.A defense-in-depth alternative is to replace the local `os.Stat + http.ServeFile` branch with a `http.FileServer(http.FS(subFS))` rooted at the embedded `admin-dist` subdirectory, which keeps the embedded-FS contract and removes the working-directory escape entirely.
CVSS v3.1: 9.1