VYPR
Medium severity5.4GHSA Advisory· Published May 23, 2026· Updated May 23, 2026

Nezha Monitoring: RoleMember can fire other users' cron tasks via AlertRule.FailTriggerTasks (no ownership check)

CVE-2026-47120

Description

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

// 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
    ...
}
// 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
}
// 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)()
    }
}
// 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.

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):

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.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

A RoleMember can attach alert rules or service monitors to another user's cron tasks, causing unauthorized execution of admin crons across servers.

Vulnerability

The createAlertRule and createService endpoints (and their update* counterparts) accept FailTriggerTasks and RecoverTriggerTasks fields containing cron task IDs. The validation functions validateRule and validateServers only check server IDs in Rules.Ignore and SkipServers maps, respectively, but never verify that the provided cron task IDs belong to the caller. Affected versions include commit 50dc8e660326b9f22990898142c58b7a5312b42a and earlier on master [1][2].

Exploitation

An authenticated user with RoleMember privileges can craft a POST /api/v1/alert-rule or POST /api/v1/service request containing FailTriggerTasks or RecoverTriggerTasks with the ID of an admin's cron task. No ownership check is performed. When the alert or service monitor triggers, CronShared.SendTriggerTasks looks up the task IDs in the global cron registry and executes them via CronTrigger. For non-AlertTrigger cover modes, the command is fanned out to every server in ServerShared.Range without any ownership verification [1][2].

Impact

A RoleMember can cause arbitrary admin cron tasks to execute across all servers (or servers in the allow/deny list) when their alert or service monitor trips. This results in unauthorized command execution with the privileges of the cron task owner (typically admin), leading to potential full compromise of monitored servers. The vulnerability bypasses restrictions on /cron write endpoints, as the attack vector uses member-accessible alert/service endpoints [1][2].

Mitigation

The advisory does not mention a specific fixed version or release date. Users should restrict access to alert rule and service creation endpoints to trusted roles, or implement input validation to ensure that FailTriggerTasks and RecoverTriggerTasks IDs belong to the authenticated user. As of the publication date, no patch is confirmed; monitor the repository for updates [1][2].

AI Insight generated on May 23, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1
  • Range: >= 1.4.0, < 1.14.15-0.20260517022419-d7526351cf97

Patches

1
d7526351cf97

fix(cron): restrict task delivery by owner

https://github.com/nezhahq/nezhanaibaMay 10, 2026via text-mined
4 files changed · +169 6
  • service/singleton/alertsentinel.go+2 2 modified
    @@ -167,7 +167,7 @@ func checkStatus() {
     					alertsPrevState[alert.ID][server.ID] = _RuleCheckFail
     					message := fmt.Sprintf("[%s] %s(%s) %s", Localizer.T("Incident"),
     						server.Name, IPDesensitize(server.GeoIP.IP.Join()), alert.Name)
    -					go CronShared.SendTriggerTasks(alert.FailTriggerTasks, curServer.ID)
    +					go CronShared.SendTriggerTasks(alert.FailTriggerTasks, curServer.ID, alert.UserID)
     					go NotificationShared.SendNotification(alert.NotificationGroupID, message, NotificationMuteLabel.ServerIncident(server.ID, alert.ID), &curServer)
     					// 清除恢复通知的静音缓存
     					NotificationShared.UnMuteNotification(alert.NotificationGroupID, NotificationMuteLabel.ServerIncidentResolved(server.ID, alert.ID))
    @@ -177,7 +177,7 @@ func checkStatus() {
     				if alertsPrevState[alert.ID][server.ID] == _RuleCheckFail {
     					message := fmt.Sprintf("[%s] %s(%s) %s", Localizer.T("Resolved"),
     						server.Name, IPDesensitize(server.GeoIP.IP.Join()), alert.Name)
    -					go CronShared.SendTriggerTasks(alert.RecoverTriggerTasks, curServer.ID)
    +					go CronShared.SendTriggerTasks(alert.RecoverTriggerTasks, curServer.ID, alert.UserID)
     					go NotificationShared.SendNotification(alert.NotificationGroupID, message, NotificationMuteLabel.ServerIncidentResolved(server.ID, alert.ID), &curServer)
     					// 清除失败通知的静音缓存
     					NotificationShared.UnMuteNotification(alert.NotificationGroupID, NotificationMuteLabel.ServerIncident(server.ID, alert.ID))
    
  • service/singleton/crontask.go+28 2 modified
    @@ -110,11 +110,11 @@ func (c *CronClass) sortList() {
     	c.sortedList = sortedList
     }
     
    -func (c *CronClass) SendTriggerTasks(taskIDs []uint64, triggerServer uint64) {
    +func (c *CronClass) SendTriggerTasks(taskIDs []uint64, triggerServer uint64, triggerOwner uint64) {
     	c.listMu.RLock()
     	var cronLists []*model.Cron
     	for _, taskID := range taskIDs {
    -		if c, ok := c.list[taskID]; ok {
    +		if c, ok := c.list[taskID]; ok && cronCanBeTriggeredByOwner(c, triggerOwner) {
     			cronLists = append(cronLists, c)
     		}
     	}
    @@ -126,6 +126,10 @@ func (c *CronClass) SendTriggerTasks(taskIDs []uint64, triggerServer uint64) {
     	}
     }
     
    +func cronCanBeTriggeredByOwner(cr *model.Cron, triggerOwner uint64) bool {
    +	return cr.UserID == triggerOwner || userIsAdmin(triggerOwner)
    +}
    +
     func ManualTrigger(cr *model.Cron) {
     	CronTrigger(cr)()
     }
    @@ -141,6 +145,9 @@ func CronTrigger(cr *model.Cron, triggerServer ...uint64) func() {
     				return
     			}
     			if s, ok := ServerShared.Get(triggerServer[0]); ok {
    +				if !cronCanSendToServer(cr, s) {
    +					return
    +				}
     				if s.TaskStream != nil {
     					s.TaskStream.Send(&pb.Task{
     						Id:   cr.ID,
    @@ -158,6 +165,9 @@ func CronTrigger(cr *model.Cron, triggerServer ...uint64) func() {
     		}
     
     		for _, s := range ServerShared.Range {
    +			if !cronCanSendToServer(cr, s) {
    +				continue
    +			}
     			if cr.Cover == model.CronCoverAll && crIgnoreMap[s.ID] {
     				continue
     			}
    @@ -179,3 +189,19 @@ func CronTrigger(cr *model.Cron, triggerServer ...uint64) func() {
     		}
     	}
     }
    +
    +func cronCanSendToServer(cr *model.Cron, server *model.Server) bool {
    +	return cr.UserID == server.UserID || userIsAdmin(cr.UserID)
    +}
    +
    +func userIsAdmin(userID uint64) bool {
    +	if userID == 0 {
    +		return true
    +	}
    +
    +	UserLock.RLock()
    +	defer UserLock.RUnlock()
    +
    +	userInfo, ok := UserInfoMap[userID]
    +	return ok && userInfo.Role.IsAdmin()
    +}
    
  • service/singleton/security_regression_test.go+137 0 added
    @@ -0,0 +1,137 @@
    +package singleton
    +
    +import (
    +	"context"
    +	"testing"
    +	"time"
    +
    +	"github.com/nezhahq/nezha/model"
    +	pb "github.com/nezhahq/nezha/proto"
    +	"google.golang.org/grpc/metadata"
    +)
    +
    +type capturedTaskStream struct {
    +	tasks chan *pb.Task
    +}
    +
    +func newCapturedTaskStream() *capturedTaskStream {
    +	return &capturedTaskStream{tasks: make(chan *pb.Task, 4)}
    +}
    +
    +func (s *capturedTaskStream) Send(task *pb.Task) error {
    +	s.tasks <- task
    +	return nil
    +}
    +
    +func (s *capturedTaskStream) Recv() (*pb.TaskResult, error) { return nil, context.Canceled }
    +func (s *capturedTaskStream) SetHeader(metadata.MD) error   { return nil }
    +func (s *capturedTaskStream) SendHeader(metadata.MD) error  { return nil }
    +func (s *capturedTaskStream) SetTrailer(metadata.MD)        {}
    +func (s *capturedTaskStream) Context() context.Context      { return context.Background() }
    +func (s *capturedTaskStream) SendMsg(any) error             { return nil }
    +func (s *capturedTaskStream) RecvMsg(any) error             { return context.Canceled }
    +
    +func replaceServerSharedForSecurityTest(t *testing.T, servers ...*model.Server) {
    +	t.Helper()
    +
    +	original := ServerShared
    +	serverClass := &ServerClass{
    +		class: class[uint64, *model.Server]{
    +			list: make(map[uint64]*model.Server),
    +		},
    +		uuidToID: make(map[string]uint64),
    +	}
    +	for _, server := range servers {
    +		serverClass.list[server.ID] = server
    +	}
    +	ServerShared = serverClass
    +	t.Cleanup(func() { ServerShared = original })
    +}
    +
    +func replaceUserInfoMapForSecurityTest(t *testing.T, users map[uint64]model.UserInfo) {
    +	t.Helper()
    +
    +	UserLock.Lock()
    +	original := UserInfoMap
    +	UserInfoMap = users
    +	UserLock.Unlock()
    +
    +	t.Cleanup(func() {
    +		UserLock.Lock()
    +		UserInfoMap = original
    +		UserLock.Unlock()
    +	})
    +}
    +
    +func TestCronTriggerSkipsServersOwnedByOtherUsers(t *testing.T) {
    +	firstStream := newCapturedTaskStream()
    +	secondStream := newCapturedTaskStream()
    +	replaceServerSharedForSecurityTest(t,
    +		&model.Server{Common: model.Common{ID: 1, UserID: 100}, Name: "member-server", TaskStream: firstStream},
    +		&model.Server{Common: model.Common{ID: 2, UserID: 200}, Name: "admin-server", TaskStream: secondStream},
    +	)
    +
    +	cronTask := &model.Cron{
    +		Common:  model.Common{ID: 99, UserID: 100},
    +		Command: "id",
    +		Cover:   model.CronCoverAll,
    +		Servers: []uint64{},
    +	}
    +
    +	CronTrigger(cronTask)()
    +
    +	assertTaskCommand(t, firstStream, "id")
    +	assertNoTask(t, secondStream)
    +}
    +
    +func TestSendTriggerTasksSkipsCronOwnedByAnotherUser(t *testing.T) {
    +	attackerStream := newCapturedTaskStream()
    +	replaceServerSharedForSecurityTest(t,
    +		&model.Server{Common: model.Common{ID: 7, UserID: 200}, Name: "attacker-server", TaskStream: attackerStream},
    +	)
    +	replaceUserInfoMapForSecurityTest(t, map[uint64]model.UserInfo{
    +		1:   {Role: model.RoleAdmin},
    +		200: {Role: model.RoleMember},
    +	})
    +
    +	adminCron := &model.Cron{
    +		Common:  model.Common{ID: 42, UserID: 1},
    +		Command: "admin-maintenance",
    +		Cover:   model.CronCoverAlertTrigger,
    +	}
    +	cronClass := &CronClass{
    +		class: class[uint64, *model.Cron]{
    +			list: map[uint64]*model.Cron{adminCron.ID: adminCron},
    +		},
    +	}
    +
    +	cronClass.SendTriggerTasks([]uint64{adminCron.ID}, 7, 200)
    +
    +	assertNoTask(t, attackerStream)
    +}
    +
    +func assertTaskCommand(t *testing.T, stream *capturedTaskStream, expectedCommand string) {
    +	t.Helper()
    +
    +	select {
    +	case task := <-stream.tasks:
    +		if task.GetType() != model.TaskTypeCommand {
    +			t.Fatalf("expected command task type, got %v", task.GetType())
    +		}
    +		if task.GetData() != expectedCommand {
    +			t.Fatalf("expected command %q, got %q", expectedCommand, task.GetData())
    +		}
    +	case <-time.After(time.Second):
    +		t.Fatalf("expected command %q to be sent", expectedCommand)
    +	}
    +}
    +
    +func assertNoTask(t *testing.T, stream *capturedTaskStream) {
    +	t.Helper()
    +
    +	select {
    +	case task := <-stream.tasks:
    +		t.Fatalf("expected no task to be sent, got command %q", task.GetData())
    +	case <-time.After(50 * time.Millisecond):
    +	}
    +}
    
  • service/singleton/servicesentinel.go+2 2 modified
    @@ -744,10 +744,10 @@ func notifyCheck(r *ReportData, m map[uint64]*model.Server,
     		reporterServer := m[r.Reporter]
     		if stateCode == StatusGood && lastStatus != stateCode {
     			// 当前状态正常 前序状态非正常时 触发恢复任务
    -			go CronShared.SendTriggerTasks(ss.RecoverTriggerTasks, reporterServer.ID)
    +			go CronShared.SendTriggerTasks(ss.RecoverTriggerTasks, reporterServer.ID, ss.UserID)
     		} else if lastStatus == StatusGood && lastStatus != stateCode {
     			// 前序状态正常 当前状态非正常时 触发失败任务
    -			go CronShared.SendTriggerTasks(ss.FailTriggerTasks, reporterServer.ID)
    +			go CronShared.SendTriggerTasks(ss.FailTriggerTasks, reporterServer.ID, ss.UserID)
     		}
     	}
     }
    

Vulnerability mechanics

Root cause

"Missing authorization check on cron task IDs in alert rule and service monitor creation allows a member to reference and trigger another user's cron tasks."

Attack vector

An authenticated `RoleMember` can craft an alert rule or service monitor via `POST /api/v1/alert-rule` or `POST /api/v1/service`, embedding arbitrary cron task IDs in the `fail_trigger_tasks` or `recover_trigger_tasks` fields. The server accepts these IDs without verifying ownership [CWE-862]. When the alert or service monitor trips (e.g., the member's agent goes offline), `SendTriggerTasks` looks up the IDs in the global cron registry and invokes `CronTrigger`, which fans the cron's command to every connected agent according to its `Cover` mode. This allows a member to execute any pre-existing admin cron task across all servers, bypassing the authorization that would normally restrict cron creation to admins.

Affected code

The vulnerability resides in `cmd/dashboard/controller/alertrule.go` and `cmd/dashboard/controller/service.go`, where `createAlertRule`, `updateAlertRule`, `createService`, and `updateService` accept `FailTriggerTasks` and `RecoverTriggerTasks` from the request body without validating that the referenced cron task IDs belong to the caller. The validation functions `validateRule` and `validateServers` only check server permissions in `rule.Ignore` and `SkipServers` maps, respectively, and never inspect the trigger task IDs. The downstream execution path in `service/singleton/crontask.go` (`SendTriggerTasks` and `CronTrigger`) performs no ownership check before looking up task IDs in the global registry and fanning out commands to agents.

What the fix does

The patch [patch_id=1664714] adds three defenses. First, `SendTriggerTasks` now accepts a `triggerOwner` parameter and filters the cron list through `cronCanBeTriggeredByOwner`, which returns true only if the cron's `UserID` matches the trigger owner or the trigger owner is an admin. Second, `CronTrigger` now calls `cronCanSendToServer` before delivering a command to any server, ensuring the cron's owner matches the server's `UserID` (or the cron owner is admin). Third, the alert and service sentinel callers pass `alert.UserID` and `ss.UserID` as the `triggerOwner`. Together these changes ensure that a member cannot trigger or deliver another user's cron task.

Preconditions

  • authAttacker must have authenticated RoleMember credentials
  • configAdmin must have at least one pre-existing cron task with Cover=CronCoverAll or Cover=CronCoverIgnoreAll
  • inputAttacker must know or guess valid admin cron task IDs
  • networkAttacker must have a server that can be taken offline to trigger the alert

Generated on May 23, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.