Nezhahq
Products
1- 2 CVEs
Recent CVEs
2| CVE | Vendor / Product | Sev | Risk | CVSS | EPSS | KEV | Published | Description |
|---|---|---|---|---|---|---|---|---|
| CVE-2026-47124 | 0.00 | — | — | May 23, 2026 | ### Summary Any authenticated non-admin member can connect to the server-status WebSocket and receive telemetry for all servers, including servers owned by other users. The normal server list API filters objects by `HasPermission`, but the WebSocket stream treats the presence of any authenticated user as authorization for the full unfiltered server list. ### Details The server WebSocket route is registered under the optional-auth group in `cmd/dashboard/controller/controller.go:71-73`: ```go optionalAuth := api.Group("", optionalAuthMw) optionalAuth.GET("/ws/server", commonHandler(serverStream)) ``` `serverStream` treats any `CtxKeyAuthorizedUser` as a member, without checking admin role or per-server ownership, in `cmd/dashboard/controller/ws.go:123-139`: ```go u, isMember := c.Get(model.CtxKeyAuthorizedUser) var userId uint64 if isMember { userId = u.(*model.User).ID } ... stat, err := getServerStat(count == 0, isMember) ``` The authorization boolean is then used as a full/guest switch in `getServerStat` in `cmd/dashboard/controller/ws.go:160-184`: ```go if authorized { serverList = singleton.ServerShared.GetSortedList() } else { serverList = singleton.ServerShared.GetSortedListForGuest() } ... servers = append(servers, model.StreamServer{ ID: server.ID, Name: server.Name, PublicNote: utils.IfOr(withPublicNote, server.PublicNote, ""), DisplayIndex: server.DisplayIndex, Host: utils.IfOr(authorized, server.Host, server.Host.Filter()), State: server.State, CountryCode: countryCode, LastActive: server.LastActive, }) ``` For authenticated members, `GetSortedList()` returns all servers and `server.Host` is not filtered. There is no call to `server.HasPermission(c)`. The streamed response model in `model/server_api.go:5-20` includes server ID/name, public note, host details, runtime state, country code, last active time, and global online count. Host and state fields include platform version, agent version, CPU/GPU names, memory/disk/swap totals, architecture, virtualization, boot time, CPU load, memory/disk/swap usage, network transfer/speed, uptime, TCP/UDP/process counts, temperatures, and GPU utilization, as defined in `model/host.go:20-38` and `model/host.go:100-112`. The normal list endpoint has the expected object-level authorization. `GET /api/v1/server` is registered with `listHandler` in `cmd/dashboard/controller/controller.go:113`, and `listHandler` filters each returned object with `HasPermission` in `cmd/dashboard/controller/controller.go:263-291`: ```go filtered := filter(c, data) ... return slices.DeleteFunc(s, func(e E) bool { return !e.HasPermission(ctx) }) ``` The shared permission model in `model/common.go:44-56` allows admins to see all objects but restricts members to objects whose `UserID` matches their user ID: ```go if user.Role == RoleAdmin { return true } return user.ID == c.UserID ``` Mitigations checked: - Guests receive `GetSortedListForGuest()` and `Host.Filter()` output, but authenticated members bypass both guest restrictions. - `HideForGuest` only affects unauthenticated guests, not members. - The normal `/api/v1/server` list endpoint uses `listHandler` and is not affected in the same way. - No owner/admin filter is applied in the WebSocket path. Candidate score: 12/14 - Reachability: 2, default WebSocket API - Attacker control: 1, attacker controls authentication state and connection - Privilege required: 1, authenticated member - Sink impact: 2, cross-tenant sensitive telemetry disclosure - Mitigation weakness: 2, no object-level auth in the WebSocket path - Default exposure: 2, endpoint is part of default dashboard - Safe PoC feasibility: 2, can be verified with local users/servers or statically Exploitability gate: statically confirmed - Reachable source: `GET /api/v1/ws/server` - Default/common configuration: dashboard API exposed by default - Missing/bypassed mitigation: member-vs-guest check replaces object-level authorization - Impact-bearing sink: WebSocket response includes unfiltered all-server telemetry - Safe proof: static source-to-sink proof; full runtime test blocked locally by unavailable Go 1.26 toolchain - Affected version evidence: confirmed at commit `85b0dd2992733037b019442caffc6c049ba937dd` (`v2.0.7-1-g85b0dd2`) - Variant review: normal server list endpoint and guest filtering were checked ### PoC Static local PoC steps: 1. Start Nezha with two non-admin users and at least one server assigned to each user. 2. Authenticate as user A. 3. Connect to the WebSocket endpoint with user A's token, for example: ```http GET /api/v1/ws/server HTTP/1.1 Host: 127.0.0.1:8008 Cookie: nz-jwt= Upgrade: websocket Connection: Upgrade ``` 4. Observe that the JSON messages contain entries for all servers from `singleton.ServerShared.GetSortedList()`, including servers whose `UserID` does not match user A. 5. Compare with `GET /api/v1/server` using the same token; that route is filtered through `listHandler`/`HasPermission` and should only return user A's own servers. Cleanup: no persistent state is created by the WebSocket connection. Local dynamic confirmation note: the full project test/runtime could not be executed in this audit environment because the repository requires Go 1.26 and the local toolchain reported `go: download go1.26 for linux/amd64: toolchain not available`. ### Impact This is an authenticated horizontal information disclosure. A low-privileged member can continuously monitor other users' server inventory and live telemetry, including host platform details, agent versions, CPU/GPU details, resource usage, traffic counters, country code, and last-active timestamps. This may expose infrastructure composition, usage patterns, and operational state across tenants. ## Suggested remediation Apply object-level authorization in `getServerStat` for authenticated non-admin users. For each server in the stream, include it only if the current user is admin or `server.UserID` matches the authenticated user. Keep guest filtering and host redaction for unauthenticated users. | |||
| CVE-2026-47120 | 0.00 | — | — | May 23, 2026 | ## Summary `createAlertRule` and `createService` (and their `update*` siblings) accept `FailTriggerTasks []uint64` and `RecoverTriggerTasks []uint64` — IDs of cron tasks to fire when the alert/service trips. The validation function only validates the alert's `Rules.Ignore` server map; it never checks that the cron task IDs in `FailTriggerTasks` / `RecoverTriggerTasks` belong to the caller. When the alert fires, `singleton.CronShared.SendTriggerTasks(taskIDs, triggerServer)` (`service/singleton/crontask.go:113-127`) looks up those task IDs in the global cron registry and executes them via `CronTrigger`. For non-`AlertTrigger` cover modes, `CronTrigger` fans the command out to every server in `ServerShared.Range` with no ownership check. Net effect: a `RoleMember` can attach their alert rule (or service monitor) to **another user's** cron task ID — including admin's crons. When the alert trips, the admin's cron command runs across every server (or every server in its allow/deny list). This is the same fanout/auth-bypass class as `NEZHA-002` (cron creation), but reachable by a different code path: even if `/cron` writes are restricted to admin, this `/alert-rule` and `/service` writes are member-reachable and let a member invoke pre-existing admin crons. ## Affected versions Commit `50dc8e660326b9f22990898142c58b7a5312b42a` and earlier on `master`. ## Reachability chain 1. `POST /api/v1/alert-rule` (or `POST /api/v1/service`) is `commonHandler`-gated — any authenticated user. 2. `createAlertRule` / `createService` accepts `FailTriggerTasks` and `RecoverTriggerTasks` from the request body without validating ownership. 3. `validateRule` (`cmd/dashboard/controller/alertrule.go:169-196`) only checks `rule.Ignore` server IDs — not the trigger task IDs. 4. `validateServers` (`cmd/dashboard/controller/service.go:543-549`) only checks the service's `SkipServers` map — not the trigger task IDs. 5. When the alert/service trips: `service/singleton/alertsentinel.go:170, 180` and `service/singleton/servicesentinel.go:747, 750` call `CronShared.SendTriggerTasks(...)`. 6. `SendTriggerTasks` (`service/singleton/crontask.go:113-127`) iterates the requested task IDs against `c.list` and calls `CronTrigger(c, triggerServer)()` for each — no ownership check. 7. `CronTrigger` then fans the cron's `Command` to every connected agent (per `Cover` rules). ## Code locations ```go // cmd/dashboard/controller/alertrule.go:47-77 func createAlertRule(c *gin.Context) (uint64, error) { var arf model.AlertRuleForm var r model.AlertRule if err := c.ShouldBindJSON(&arf); err != nil { return 0, err } uid := getUid(c) r.UserID = uid r.Name = arf.Name r.Rules = arf.Rules r.FailTriggerTasks = arf.FailTriggerTasks // <-- attacker-controlled task IDs r.RecoverTriggerTasks = arf.RecoverTriggerTasks // <-- ditto r.NotificationGroupID = arf.NotificationGroupID enable := arf.Enable r.TriggerMode = arf.TriggerMode r.Enable = &enable if err := validateRule(c, &r); err != nil { return 0, err } // only checks rule.Ignore servers ... } ``` ```go // cmd/dashboard/controller/alertrule.go:169-196 func validateRule(c *gin.Context, r *model.AlertRule) error { if len(r.Rules) > 0 { for _, rule := range r.Rules { if !singleton.ServerShared.CheckPermission(c, maps.Keys(rule.Ignore)) { return singleton.Localizer.ErrorT("permission denied") } // ... duration/cycle validation only } } // BUG: no check on r.FailTriggerTasks or r.RecoverTriggerTasks ownership. return nil } ``` ```go // service/singleton/crontask.go:113-127 func (c *CronClass) SendTriggerTasks(taskIDs []uint64, triggerServer uint64) { c.listMu.RLock() var cronLists []*model.Cron for _, taskID := range taskIDs { if c, ok := c.list[taskID]; ok { // <-- looks up ANY cron in global state cronLists = append(cronLists, c) } } c.listMu.RUnlock() // BUG: no ownership check between alert.UserID and cron.UserID before invoking. for _, c := range cronLists { go CronTrigger(c, triggerServer)() } } ``` ```go // service/singleton/crontask.go:138-181 — CronTrigger return func() { if cr.Cover == model.CronCoverAlertTrigger { // alert-only: only sends to triggerServer (the member's server, when alert was triggered by it) if s, ok := ServerShared.Get(triggerServer[0]); ok && s.TaskStream != nil { s.TaskStream.Send(&pb.Task{Id: cr.ID, Data: cr.Command, Type: model.TaskTypeCommand}) } return } // For Cover=CronCoverAll or CronCoverIgnoreAll: fan out to every server. for _, s := range ServerShared.Range { if cr.Cover == model.CronCoverAll && crIgnoreMap[s.ID] { continue } if cr.Cover == model.CronCoverIgnoreAll && !crIgnoreMap[s.ID] { continue } if s.TaskStream != nil { s.TaskStream.Send(&pb.Task{Id: cr.ID, Data: cr.Command, Type: model.TaskTypeCommand}) } } } ``` ## PoC Pre-conditions: attacker has `RoleMember` credentials. Admin has at least one pre-existing cron with `Cover=CronCoverAll` or `Cover=CronCoverIgnoreAll` (i.e., a "run on all servers" maintenance cron — common in monitoring deployments). Step 1: Enumerate admin cron IDs by ID-guessing. Try IDs 1..N; create AlertRule referencing each, see if the alert handler accepts. Step 2: Create an alert rule referencing the admin's cron and pointed at an offline-trigger condition on the member's own server. ```bash TOKEN=$(curl -sX POST -H 'Content-Type: application/json' \ -d '{"username":"member","password":"hunter2"}' \ http://nezha.example.com/api/v1/login | jq -r .token) curl -sX POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"name":"trip","rules":[{"type":"offline","duration":3,"min":1.0,"cover":"member-server-id"}],"fail_trigger_tasks":[1,2,3,4,5],"recover_trigger_tasks":[],"notification_group_id":0,"trigger_mode":0,"enable":true}' \ http://nezha.example.com/api/v1/alert-rule ``` Step 3: Stop the agent on the member's own server (or unplug it). The alert trips after `duration` seconds. `SendTriggerTasks([1,2,3,4,5], member-server-id)` runs. Step 4: For each cron ID in the list, if that cron exists in the global registry and has `Cover=CronCoverAll/IgnoreAll`, its `Command` runs on every server. The same chain works via `POST /api/v1/service` (service-monitor with `fail_trigger_tasks`). ## Composability with NEZHA-002 If `NEZHA-002` is unfixed, this chain is redundant — the member already has direct cron-create access. With `NEZHA-002` fixed, this still gives the member a means to invoke any **pre-existing** admin cron with the member's chosen trigger condition. The fix surface is also independent (alertrule/service write paths, not /cron writes). ## Suggested fix In `validateRule` (and `validateServers`): ```go if !singleton.CronShared.CheckPermission(c, slices.Values(r.FailTriggerTasks)) { return singleton.Localizer.ErrorT("permission denied") } if !singleton.CronShared.CheckPermission(c, slices.Values(r.RecoverTriggerTasks)) { return singleton.Localizer.ErrorT("permission denied") } ``` Defense-in-depth in `SendTriggerTasks`: enforce that `task.UserID == alert.UserID || alertOwnerIsAdmin || taskOwnerIsAdmin`. ## Severity - PR:L because RoleMember credentials needed. - AC:H because attacker has to ID-guess admin cron IDs and have an alert-trip vector. (For a deployment where the attacker has visibility into max cron ID via UI hints or the `id`-query echo, AC drops to L.) - S:C because the cron command runs on every connected agent (different trust zone). - **Auth:** authenticated `RoleMember`. ## Reproduction environment - Tested against: `nezhahq/nezha` master @ `50dc8e660326b9f22990898142c58b7a5312b42a`. - Code locations: - `cmd/dashboard/controller/alertrule.go:47-77` (createAlertRule), 91-131 (updateAlertRule), 169-196 (validateRule) - `cmd/dashboard/controller/service.go:404-445` (createService), 459-509 (updateService), 543-549 (validateServers) - `service/singleton/crontask.go:113-127` (SendTriggerTasks), 133-181 (CronTrigger) - `service/singleton/alertsentinel.go:170, 180` (alert-fire callsite) - `service/singleton/servicesentinel.go:742-750` (service-fire callsite) ## Reporter Eddie Ran. Filed via reporter API. Companion to NEZHA-001/002 — same auth-bypass class but a different write path. |
- CVE-2026-47124May 23, 2026risk 0.00cvss —epss —
### Summary Any authenticated non-admin member can connect to the server-status WebSocket and receive telemetry for all servers, including servers owned by other users. The normal server list API filters objects by `HasPermission`, but the WebSocket stream treats the presence of any authenticated user as authorization for the full unfiltered server list. ### Details The server WebSocket route is registered under the optional-auth group in `cmd/dashboard/controller/controller.go:71-73`: ```go optionalAuth := api.Group("", optionalAuthMw) optionalAuth.GET("/ws/server", commonHandler(serverStream)) ``` `serverStream` treats any `CtxKeyAuthorizedUser` as a member, without checking admin role or per-server ownership, in `cmd/dashboard/controller/ws.go:123-139`: ```go u, isMember := c.Get(model.CtxKeyAuthorizedUser) var userId uint64 if isMember { userId = u.(*model.User).ID } ... stat, err := getServerStat(count == 0, isMember) ``` The authorization boolean is then used as a full/guest switch in `getServerStat` in `cmd/dashboard/controller/ws.go:160-184`: ```go if authorized { serverList = singleton.ServerShared.GetSortedList() } else { serverList = singleton.ServerShared.GetSortedListForGuest() } ... servers = append(servers, model.StreamServer{ ID: server.ID, Name: server.Name, PublicNote: utils.IfOr(withPublicNote, server.PublicNote, ""), DisplayIndex: server.DisplayIndex, Host: utils.IfOr(authorized, server.Host, server.Host.Filter()), State: server.State, CountryCode: countryCode, LastActive: server.LastActive, }) ``` For authenticated members, `GetSortedList()` returns all servers and `server.Host` is not filtered. There is no call to `server.HasPermission(c)`. The streamed response model in `model/server_api.go:5-20` includes server ID/name, public note, host details, runtime state, country code, last active time, and global online count. Host and state fields include platform version, agent version, CPU/GPU names, memory/disk/swap totals, architecture, virtualization, boot time, CPU load, memory/disk/swap usage, network transfer/speed, uptime, TCP/UDP/process counts, temperatures, and GPU utilization, as defined in `model/host.go:20-38` and `model/host.go:100-112`. The normal list endpoint has the expected object-level authorization. `GET /api/v1/server` is registered with `listHandler` in `cmd/dashboard/controller/controller.go:113`, and `listHandler` filters each returned object with `HasPermission` in `cmd/dashboard/controller/controller.go:263-291`: ```go filtered := filter(c, data) ... return slices.DeleteFunc(s, func(e E) bool { return !e.HasPermission(ctx) }) ``` The shared permission model in `model/common.go:44-56` allows admins to see all objects but restricts members to objects whose `UserID` matches their user ID: ```go if user.Role == RoleAdmin { return true } return user.ID == c.UserID ``` Mitigations checked: - Guests receive `GetSortedListForGuest()` and `Host.Filter()` output, but authenticated members bypass both guest restrictions. - `HideForGuest` only affects unauthenticated guests, not members. - The normal `/api/v1/server` list endpoint uses `listHandler` and is not affected in the same way. - No owner/admin filter is applied in the WebSocket path. Candidate score: 12/14 - Reachability: 2, default WebSocket API - Attacker control: 1, attacker controls authentication state and connection - Privilege required: 1, authenticated member - Sink impact: 2, cross-tenant sensitive telemetry disclosure - Mitigation weakness: 2, no object-level auth in the WebSocket path - Default exposure: 2, endpoint is part of default dashboard - Safe PoC feasibility: 2, can be verified with local users/servers or statically Exploitability gate: statically confirmed - Reachable source: `GET /api/v1/ws/server` - Default/common configuration: dashboard API exposed by default - Missing/bypassed mitigation: member-vs-guest check replaces object-level authorization - Impact-bearing sink: WebSocket response includes unfiltered all-server telemetry - Safe proof: static source-to-sink proof; full runtime test blocked locally by unavailable Go 1.26 toolchain - Affected version evidence: confirmed at commit `85b0dd2992733037b019442caffc6c049ba937dd` (`v2.0.7-1-g85b0dd2`) - Variant review: normal server list endpoint and guest filtering were checked ### PoC Static local PoC steps: 1. Start Nezha with two non-admin users and at least one server assigned to each user. 2. Authenticate as user A. 3. Connect to the WebSocket endpoint with user A's token, for example: ```http GET /api/v1/ws/server HTTP/1.1 Host: 127.0.0.1:8008 Cookie: nz-jwt= Upgrade: websocket Connection: Upgrade ``` 4. Observe that the JSON messages contain entries for all servers from `singleton.ServerShared.GetSortedList()`, including servers whose `UserID` does not match user A. 5. Compare with `GET /api/v1/server` using the same token; that route is filtered through `listHandler`/`HasPermission` and should only return user A's own servers. Cleanup: no persistent state is created by the WebSocket connection. Local dynamic confirmation note: the full project test/runtime could not be executed in this audit environment because the repository requires Go 1.26 and the local toolchain reported `go: download go1.26 for linux/amd64: toolchain not available`. ### Impact This is an authenticated horizontal information disclosure. A low-privileged member can continuously monitor other users' server inventory and live telemetry, including host platform details, agent versions, CPU/GPU details, resource usage, traffic counters, country code, and last-active timestamps. This may expose infrastructure composition, usage patterns, and operational state across tenants. ## Suggested remediation Apply object-level authorization in `getServerStat` for authenticated non-admin users. For each server in the stream, include it only if the current user is admin or `server.UserID` matches the authenticated user. Keep guest filtering and host redaction for unauthenticated users.
- CVE-2026-47120May 23, 2026risk 0.00cvss —epss —
## Summary `createAlertRule` and `createService` (and their `update*` siblings) accept `FailTriggerTasks []uint64` and `RecoverTriggerTasks []uint64` — IDs of cron tasks to fire when the alert/service trips. The validation function only validates the alert's `Rules.Ignore` server map; it never checks that the cron task IDs in `FailTriggerTasks` / `RecoverTriggerTasks` belong to the caller. When the alert fires, `singleton.CronShared.SendTriggerTasks(taskIDs, triggerServer)` (`service/singleton/crontask.go:113-127`) looks up those task IDs in the global cron registry and executes them via `CronTrigger`. For non-`AlertTrigger` cover modes, `CronTrigger` fans the command out to every server in `ServerShared.Range` with no ownership check. Net effect: a `RoleMember` can attach their alert rule (or service monitor) to **another user's** cron task ID — including admin's crons. When the alert trips, the admin's cron command runs across every server (or every server in its allow/deny list). This is the same fanout/auth-bypass class as `NEZHA-002` (cron creation), but reachable by a different code path: even if `/cron` writes are restricted to admin, this `/alert-rule` and `/service` writes are member-reachable and let a member invoke pre-existing admin crons. ## Affected versions Commit `50dc8e660326b9f22990898142c58b7a5312b42a` and earlier on `master`. ## Reachability chain 1. `POST /api/v1/alert-rule` (or `POST /api/v1/service`) is `commonHandler`-gated — any authenticated user. 2. `createAlertRule` / `createService` accepts `FailTriggerTasks` and `RecoverTriggerTasks` from the request body without validating ownership. 3. `validateRule` (`cmd/dashboard/controller/alertrule.go:169-196`) only checks `rule.Ignore` server IDs — not the trigger task IDs. 4. `validateServers` (`cmd/dashboard/controller/service.go:543-549`) only checks the service's `SkipServers` map — not the trigger task IDs. 5. When the alert/service trips: `service/singleton/alertsentinel.go:170, 180` and `service/singleton/servicesentinel.go:747, 750` call `CronShared.SendTriggerTasks(...)`. 6. `SendTriggerTasks` (`service/singleton/crontask.go:113-127`) iterates the requested task IDs against `c.list` and calls `CronTrigger(c, triggerServer)()` for each — no ownership check. 7. `CronTrigger` then fans the cron's `Command` to every connected agent (per `Cover` rules). ## Code locations ```go // cmd/dashboard/controller/alertrule.go:47-77 func createAlertRule(c *gin.Context) (uint64, error) { var arf model.AlertRuleForm var r model.AlertRule if err := c.ShouldBindJSON(&arf); err != nil { return 0, err } uid := getUid(c) r.UserID = uid r.Name = arf.Name r.Rules = arf.Rules r.FailTriggerTasks = arf.FailTriggerTasks // <-- attacker-controlled task IDs r.RecoverTriggerTasks = arf.RecoverTriggerTasks // <-- ditto r.NotificationGroupID = arf.NotificationGroupID enable := arf.Enable r.TriggerMode = arf.TriggerMode r.Enable = &enable if err := validateRule(c, &r); err != nil { return 0, err } // only checks rule.Ignore servers ... } ``` ```go // cmd/dashboard/controller/alertrule.go:169-196 func validateRule(c *gin.Context, r *model.AlertRule) error { if len(r.Rules) > 0 { for _, rule := range r.Rules { if !singleton.ServerShared.CheckPermission(c, maps.Keys(rule.Ignore)) { return singleton.Localizer.ErrorT("permission denied") } // ... duration/cycle validation only } } // BUG: no check on r.FailTriggerTasks or r.RecoverTriggerTasks ownership. return nil } ``` ```go // service/singleton/crontask.go:113-127 func (c *CronClass) SendTriggerTasks(taskIDs []uint64, triggerServer uint64) { c.listMu.RLock() var cronLists []*model.Cron for _, taskID := range taskIDs { if c, ok := c.list[taskID]; ok { // <-- looks up ANY cron in global state cronLists = append(cronLists, c) } } c.listMu.RUnlock() // BUG: no ownership check between alert.UserID and cron.UserID before invoking. for _, c := range cronLists { go CronTrigger(c, triggerServer)() } } ``` ```go // service/singleton/crontask.go:138-181 — CronTrigger return func() { if cr.Cover == model.CronCoverAlertTrigger { // alert-only: only sends to triggerServer (the member's server, when alert was triggered by it) if s, ok := ServerShared.Get(triggerServer[0]); ok && s.TaskStream != nil { s.TaskStream.Send(&pb.Task{Id: cr.ID, Data: cr.Command, Type: model.TaskTypeCommand}) } return } // For Cover=CronCoverAll or CronCoverIgnoreAll: fan out to every server. for _, s := range ServerShared.Range { if cr.Cover == model.CronCoverAll && crIgnoreMap[s.ID] { continue } if cr.Cover == model.CronCoverIgnoreAll && !crIgnoreMap[s.ID] { continue } if s.TaskStream != nil { s.TaskStream.Send(&pb.Task{Id: cr.ID, Data: cr.Command, Type: model.TaskTypeCommand}) } } } ``` ## PoC Pre-conditions: attacker has `RoleMember` credentials. Admin has at least one pre-existing cron with `Cover=CronCoverAll` or `Cover=CronCoverIgnoreAll` (i.e., a "run on all servers" maintenance cron — common in monitoring deployments). Step 1: Enumerate admin cron IDs by ID-guessing. Try IDs 1..N; create AlertRule referencing each, see if the alert handler accepts. Step 2: Create an alert rule referencing the admin's cron and pointed at an offline-trigger condition on the member's own server. ```bash TOKEN=$(curl -sX POST -H 'Content-Type: application/json' \ -d '{"username":"member","password":"hunter2"}' \ http://nezha.example.com/api/v1/login | jq -r .token) curl -sX POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"name":"trip","rules":[{"type":"offline","duration":3,"min":1.0,"cover":"member-server-id"}],"fail_trigger_tasks":[1,2,3,4,5],"recover_trigger_tasks":[],"notification_group_id":0,"trigger_mode":0,"enable":true}' \ http://nezha.example.com/api/v1/alert-rule ``` Step 3: Stop the agent on the member's own server (or unplug it). The alert trips after `duration` seconds. `SendTriggerTasks([1,2,3,4,5], member-server-id)` runs. Step 4: For each cron ID in the list, if that cron exists in the global registry and has `Cover=CronCoverAll/IgnoreAll`, its `Command` runs on every server. The same chain works via `POST /api/v1/service` (service-monitor with `fail_trigger_tasks`). ## Composability with NEZHA-002 If `NEZHA-002` is unfixed, this chain is redundant — the member already has direct cron-create access. With `NEZHA-002` fixed, this still gives the member a means to invoke any **pre-existing** admin cron with the member's chosen trigger condition. The fix surface is also independent (alertrule/service write paths, not /cron writes). ## Suggested fix In `validateRule` (and `validateServers`): ```go if !singleton.CronShared.CheckPermission(c, slices.Values(r.FailTriggerTasks)) { return singleton.Localizer.ErrorT("permission denied") } if !singleton.CronShared.CheckPermission(c, slices.Values(r.RecoverTriggerTasks)) { return singleton.Localizer.ErrorT("permission denied") } ``` Defense-in-depth in `SendTriggerTasks`: enforce that `task.UserID == alert.UserID || alertOwnerIsAdmin || taskOwnerIsAdmin`. ## Severity - PR:L because RoleMember credentials needed. - AC:H because attacker has to ID-guess admin cron IDs and have an alert-trip vector. (For a deployment where the attacker has visibility into max cron ID via UI hints or the `id`-query echo, AC drops to L.) - S:C because the cron command runs on every connected agent (different trust zone). - **Auth:** authenticated `RoleMember`. ## Reproduction environment - Tested against: `nezhahq/nezha` master @ `50dc8e660326b9f22990898142c58b7a5312b42a`. - Code locations: - `cmd/dashboard/controller/alertrule.go:47-77` (createAlertRule), 91-131 (updateAlertRule), 169-196 (validateRule) - `cmd/dashboard/controller/service.go:404-445` (createService), 459-509 (updateService), 543-549 (validateServers) - `service/singleton/crontask.go:113-127` (SendTriggerTasks), 133-181 (CronTrigger) - `service/singleton/alertsentinel.go:170, 180` (alert-fire callsite) - `service/singleton/servicesentinel.go:742-750` (service-fire callsite) ## Reporter Eddie Ran. Filed via reporter API. Companion to NEZHA-001/002 — same auth-bypass class but a different write path.