medium

CVE-2026-53520

Go · github.com/nezhahq/nezha

Summary

Nezha Monitoring: Authenticated users can claim the dashboard Host through NAT and preempt all dashboard routing

Severity
medium
CVSS
6.5
EPSS
0.3% (p21)
CWE
CWE-284
Also known as
GHSA-x6fg-52vr-hj4w
Published
2026-06-26
Updated
2026-06-26

Advisory details

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:

Candidate score: 16/18.

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:

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 <<'EOF'
package docs

var SwaggerInfo = struct{ Version string }{Version: "test"}
EOF
cat > 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-age

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.