CVE-2026-47120: Nezha Monitoring RoleMember Can Fire Other Users' Vulnerability

MODERATE
Advisory Identifier: GHSA-rxf6-wjh4-jfj6
CVSS 5.4
CWE-862 CWE-863
Published
Advisory Summary

CVE-2026-47120 is a MODERATE (CVSS 5.4) rolemember can fire other users' vulnerability (CWE-862: Missing Authorization) affecting Nezha Monitoring libraries in the Go ecosystem. The issue involves rolemember can fire other users' cron tasks via alertrule.failtriggertasks (no ownership check). Remediation is available by upgrading affected packages to version 1.14.15-0.20260517022419-d7526351cf97.

CVE-2026-47120 at a Glance

CVE Identifier CVE-2026-47120
GHSA Identifier GHSA-rxf6-wjh4-jfj6
Severity MODERATE
CVSS v3.1 Base Score 5.4
CWE Classification CWE-862: Missing Authorization
Affected Product Nezha Monitoring
Package Ecosystem Go
Published Date
Last Updated

Security Impact

CVSS: 5.4

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:L/A:L).

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 Low (I:L)
Availability Low (A:L)

How to Fix CVE-2026-47120

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: 1.14.15-0.20260517022419-d7526351cf97
Upgrade github.com/nezhahq/nezha to 1.14.15-0.20260517022419-d7526351cf97
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-47120, 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 1.14.15-0.20260517022419-d7526351cf97), 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-862: Missing Authorization, where the product does not perform an authorization check when an actor attempts to access a resource or perform an action. 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 `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-47120 Affected Nezha Monitoring Versions

1 component(s)
Fixed Versions:
1.14.15-0.20260517022419-d7526351cf97
Product / Package Ecosystem Affected Versions Fixed Version
github.com/nezhahq/nezha
Go
≥ 1.4.0
< 1.14.15-0.20260517022419-d7526351cf97
1.14.15-0.20260517022419-d7526351cf97

References

Frequently Asked Questions

What is CVE-2026-47120?
CVE-2026-47120 is a security vulnerability in Nezha Monitoring involving RoleMember can fire other users' cron tasks via AlertRule.FailTriggerTasks (no ownership check) under CWE-862: Missing Authorization.
Which Nezha Monitoring versions are affected?
Affected versions include releases prior to 1.14.15-0.20260517022419-d7526351cf97 across affected packages: github.com/nezhahq/nezha.
What version fixes CVE-2026-47120?
Upgrading to 1.14.15-0.20260517022419-d7526351cf97 (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 1.14.15-0.20260517022419-d7526351cf97. Upgrade your dependency to 1.14.15-0.20260517022419-d7526351cf97 or later.
What is the CVSS score of CVE-2026-47120?
CVE-2026-47120 has a CVSS v3.1 base score of 5.4 (MODERATE) with vector string CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L.
What CWE is associated with CVE-2026-47120?
CVE-2026-47120 is classified under CWE-862: Missing Authorization (The product does not perform an authorization check when an actor attempts to access a resource or perform an action.).
How can I check Go dependencies for CVE-2026-47120?
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-rxf6-wjh4-jfj6",
  "modified" : "2026-06-26T21:28:31Z",
  "published" : "2026-05-23T00:08:45Z",
  "aliases" : [ "CVE-2026-47120" ],
  "summary" : "Nezha Monitoring: RoleMember can fire other users' cron tasks via AlertRule.FailTriggerTasks (no ownership check)",
  "details" : "## Summary\n\n`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.\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'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).\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 — 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 — not the trigger task IDs.\n4. `validateServers` (`cmd/dashboard/controller/service.go:543-549`) only checks the service's `SkipServers` map — 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 — no ownership check.\n7. `CronTrigger` then fans the cron's `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(&arf); 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       // <-- attacker-controlled task IDs\n    r.RecoverTriggerTasks = arf.RecoverTriggerTasks // <-- ditto\n    r.NotificationGroupID = arf.NotificationGroupID\n    enable := arf.Enable\n    r.TriggerMode = arf.TriggerMode\n    r.Enable = &enable\n\n    if err := validateRule(c, &r); 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) > 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 {                 // <-- 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 — CronTrigger\nreturn func() {\n    if cr.Cover == model.CronCoverAlertTrigger {\n        // alert-only: only sends to triggerServer (the member's server, when alert was triggered by it)\n        if s, ok := ServerShared.Get(triggerServer[0]); ok && s.TaskStream != nil {\n            s.TaskStream.Send(&pb.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 && crIgnoreMap[s.ID] { continue }\n        if cr.Cover == model.CronCoverIgnoreAll && !crIgnoreMap[s.ID] { continue }\n        if s.TaskStream != nil {\n            s.TaskStream.Send(&pb.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 — 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's cron and pointed at an offline-trigger condition on the member's own server.\n\n```bash\nTOKEN=$(curl -sX POST -H 'Content-Type: application/json' \\\n    -d '{\"username\":\"member\",\"password\":\"hunter2\"}' \\\n    http://nezha.example.com/api/v1/login | jq -r .token)\n\ncurl -sX POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' \\\n    -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}' \\\n    http://nezha.example.com/api/v1/alert-rule\n```\n\nStep 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.\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 — 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).\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 — same auth-bypass class but a different write path.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/nezhahq/nezha"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "1.4.0"
      }, {
        "fixed" : "1.14.15-0.20260517022419-d7526351cf97"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/nezhahq/nezha/security/advisories/GHSA-rxf6-wjh4-jfj6"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-47120"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/nezhahq/nezha"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-862", "CWE-863" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-05-23T00:08:45Z",
    "nvd_published_at" : "2026-06-12T22:16:51Z"
  }
}