GitHub Advisoryadvisory · Go
GHSA-x6fg-52vr-hj4w Nezha Monitoring: Authenticated users can claim the dashboard Host through NAT and preempt all dashboard routing
MEDIUM
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