GitHub Advisoryadvisory · Go

GHSA-x6fg-52vr-hj4w Nezha Monitoring: Authenticated users can claim the dashboard Host through NAT and preempt all dashboard routing

MEDIUM Published 2026-06-26 Modified 2026-06-26 Official page ↗
Aliases
CVE-2026-53520
Ecosystem
Go
Packages
github.com/nezhahq/nezha
Severity
Moderate
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
Weaknesses
CWE-284
Reviewed
2026-06-26

Affected packages

• Go github.com/nezhahq/nezha: >= 2.0.14, < 2.1.0 → fixed in 2.1.0

Description

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/nat_api.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/request_wrapper.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 `listen_port` 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/claude_nat_poc_placeholder.txt cmd/dashboard/user-dist/claude_nat_poc_placeholder.txt cmd/dashboard/docs/docs.go cmd/dashboard/nat_host_claim_tmp_test.go; rmdir cmd/dashboard/docs 2>/dev/null || true; } cleanup mkdir -p cmd/dashboard/docs printf 'placeholder' > cmd/dashboard/admin-dist/claude_nat_poc_placeholder.txt printf 'placeholder' > cmd/dashboard/user-dist/claude_nat_poc_placeholder.txt cat > cmd/dashboard/docs/docs.go cmd/dashboard/nat_host_claim_tmp_test.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…

Related

Alias · CVE (1)

  • CVE-2026-53520 Nezha Monitoring is a self-hostable, lightweight, servers and websites monitoring and O&M tool. vulnerability

Weakness · CWE (1)

  • CWE-284 Improper Access Control weakness

References