VulnWatch VulnWatch
← Back to dashboard
Medium github · GHSA-x6fg-52vr-hj4w

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

Published Jun 26, 2026 CVSS 6.5

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:

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

Affected AI Products

claude
Get the weekly digest. Every Monday: top AI security stories of the week. Free.