CVE-2026-49397: Github.com/nezhahq/nezha False`) Are Enumerable Vulnerability

MODERATE
Advisory Identifier: GHSA-vrmh-5mmx-hjwx
CVSS 5.3
CWE-285 CWE-200 CWE-863
Published
Advisory Summary

CVE-2026-49397 is a MODERATE (CVSS 5.3) false`) are enumerable vulnerability (CWE-285: Improper Authorization) affecting Github.com/nezhahq/nezha libraries in the Go ecosystem. The issue involves false`) are enumerable via per-server endpoints, leaking name and timing data. Remediation is available by upgrading affected packages to version 2.0.14.

CVE-2026-49397 at a Glance

CVE Identifier CVE-2026-49397
GHSA Identifier GHSA-vrmh-5mmx-hjwx
Severity MODERATE
CVSS v3.1 Base Score 5.3
CWE Classification CWE-285: Improper Authorization
Affected Product Github.com/nezhahq/nezha
Package Ecosystem Go
Published Date
Last Updated

Security Impact

CVSS: 5.3

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

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

How to Fix CVE-2026-49397

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

github.com/nezhahq/nezha fixed: 2.0.14
Upgrade github.com/nezhahq/nezha to 2.0.14
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-49397, inspect your project dependencies for Github.com/nezhahq/nezha:

inspect dependencies for github.com/nezhahq/nezha

Analyze the terminal output to locate any version of Github.com/nezhahq/nezha in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2.0.14), 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-285: Improper Authorization, where the product does not perform or incorrectly performs an authorization check when an actor attempts to access a resource or perform an action. In Github.com/nezhahq/nezha, 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

# Private services (`EnableShowInService: false`) are enumerable via per-server endpoints, leaking name and timing data **CWE**: CWE-285 (Improper Authorization) via CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor) and CWE-863 (Incorrect Authorization — inconsistent gating across data-reader paths) **CVSS v3.1**: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N` → 5.3 (Medium) ## Summary The `EnableShowInService` flag on a `Service` is meant to gate that service's visibility from the public dashboard. The main service-listing endpoint (`GET /api/v1/service` → `showService`) correctly filters services with `EnableShowInService: false` via `ServiceSentinel.CopyStats()` (`service/singleton/servicesentinel.go:421-438`). However, two adjacent reader endpoints retrieve service objects through code paths that do not honor the same flag: - `GET /api/v1/server/:id/service` (`listServerServices`) iterates `ServiceSentinel.GetSortedList()` (which returns every service regardless of visibility) and emits service ID, name, and timing data for any service monitoring the queried server. - `GET /api/v1/service/:id/history` (`getServiceHistory`) calls `ServiceSentinel.Get(serviceID)` directly and emits the service name (and aggregated per-server stats for servers the viewer can see). Both endpoints are mounted on the `optionalAuth` group, so an unauthenticated visitor can enumerate hidden services as long as they can guess a public server ID (linear scan over a small numeric ID space) or a service ID (likewise). The service owner's intent — "hide this from the public" via `EnableShowInService: false` — is silently bypassed. ## Affected - nezha `master` at HEAD `636f4a99e6c3d8d75f17fdf7ad55d4ee0f73f1c0` (the audit checkout) - All recent 2.x releases that share this code path (post the `EnableShowInService` filter introduction at `CopyStats`) ## Vulnerability details ### [A] — single-source-of-truth filter exists at the listing site `service/singleton/servicesentinel.go:421-438`: ```go func (ss *ServiceSentinel) CopyStats() map[uint64]model.ServiceResponseItem { var stats map[uint64]*serviceResponseItem copier.Copy(&stats, ss.LoadStats()) sri := make(map[uint64]model.ServiceResponseItem) for k, service := range stats { if !service.service.EnableShowInService { // [A] filter here delete(stats, k) continue } service.ServiceName = service.service.Name sri[k] = service.ServiceResponseItem } return sri } ``` `CopyStats()` is the only reader that respects `EnableShowInService`. `Get()` and `GetSortedList()` immediately below it return the raw services with no such filter: ```go func (ss *ServiceSentinel) Get(id uint64) (s *model.Service, ok bool) { ss.servicesLock.RLock(); defer ss.servicesLock.RUnlock() s, ok = ss.services[id] return // [A'] no EnableShowInService check } ``` ### [B] — `listServerServices` iterates `GetSortedList()` and emits hidden services `cmd/dashboard/controller/service.go:258-340` (`GET /api/v1/server/:id/service`): ```go func listServerServices(c *gin.Context) ([]*model.ServiceInfos, error) { // ... server existence + userCanViewServer check ... services := singleton.ServiceSentinelShared.GetSortedList() // [B] all services, no filter for _, service := range services { if service.Cover == model.ServiceCoverAll { if service.SkipServers[serverID] { continue } } else { if !service.SkipServers[serverID] { continue } } // ... fetch history ... infos := &model.ServiceInfos{ ServiceID: service.ID, ServerID: serverID, ServiceName: service.Name, // [B'] leaked ServerName: server.Name, // ... timing data ... } result = append(result, infos) } return result, nil } ``` The DB-fallback path at `queryServerServicesFromDB` (`service.go:340-`) has the same structure: iterates `services` (the same `GetSortedList()` output) and emits ServiceName for any service monitoring `serverID`. ### [C] — `getServiceHistory` returns the service name for any ID `cmd/dashboard/controller/service.go:126-180` (`GET /api/v1/service/:id/history`): ```go func getServiceHistory(c *gin.Context) (*model.ServiceHistoryResponse, error) { serviceID, _ := strconv.ParseUint(c.Param("id"), 10, 64) service, ok := singleton.ServiceSentinelShared.Get(serviceID) // [C] no filter if !ok || service == nil { return nil, singleton.Localizer.ErrorT("service not found") } // period restriction for guests (1d only) — but the service exists, // and ServiceName is set unconditionally: response := &model.ServiceHistoryResponse{ ServiceID: serviceID, ServiceName: service.Name, // [C'] leaked Servers: make([]model.ServerServiceStats, 0), } // ... per-server data is filtered via userCanViewServer — that part is correct ... return response, nil } ``` The per-server data inside the response IS correctly filtered via `userCanViewServer`. The service NAME is not. ### The mismatch [A] (`CopyStats`) gates by `EnableShowInService` because that's the listing endpoint's contract. [A'] (`Get`) / `GetSortedList()` return the raw data because they're "internal" accessors. But [B] and [C] are public-reachable endpoints that use those raw accessors and emit identifying information about services the owner marked as private. The visibility flag exists; it just isn't enforced at every reader of the same data. A correct guard would either: - Move the `EnableShowInService` filter into `Get()` / `GetSortedList()` themselves, gated by "caller is admin or service owner" - Re-check `EnableShowInService` at every endpoint that emits service identity (name/id/timing) ## Proof of concept Setup (any nezha 2.x deployment): 1. User A (member) creates a Service "Internal-CRM-Health" with `EnableShowInService: false`, monitoring server `S` which is public (`HideForGuest: false`). 2. The service does not appear in `GET /api/v1/service` (the main listing correctly hides it). Enumeration as an unauthenticated guest: ```bash # Find services that monitor server S curl -s 'https://nezha.example/api/v1/server/'"$S_ID"'/service' # → # {"success":true,"data":[ # {"service_id":42,"server_id":1,"service_name":"Internal-CRM-Health","server_name":"web-01", # "display_index":0,"created_at":[...],"avg_delay":[...]} # ]} # # Hidden service is leaked: ID, name, and per-server timing data are all visible. ``` Confirmation via the second endpoint: ```bash curl -s 'https://nezha.example/api/v1/service/42/history?period=1d' # → # {"success":true,"data":{ # "service_id":42, # "service_name":"Internal-CRM-Health", ← leaked even for direct ID lookup # "servers":[] ← per-server data correctly hidden # }} ``` A scripted enumeration over public server IDs (a low-cardinality numeric space — typical nezha deployments have <1000 servers) trivially recovers the full set of hidden services that monitor any public server, along with their names and timing patterns. ## Impact ### Direct Service names in nezha deployments are frequently descriptive of the underlying business asset they monitor: `"Production CRM Monitor"`, `"Internal Wiki Health"`, `"Backup-Vault Connectivity"`, `"Stripe Webhook Latency"`. The leak therefore: - **Discloses the existence and purpose of internal services** that the owner explicitly hid from the public dashboard. - **Exposes timing/latency data** for the monitored relationship between a private service and any public server it touches — sufficient for a competitor or attacker to infer business activity patterns, outage windows, and probable backend topology. - **Confirms presence/absence of a service ID** via the second endpoint — an oracle that lets an unauthenticated visitor enumerate the service-id namespace and learn the deployment's service count and naming convention even when no public servers exist as enumeration vectors. ### Indirect / second-order - **Affects multi-tenant public dashboards**: nezha is frequently deployed as a public status page with a private "internal" tier in the same dashboard. The bypass collapses the privacy boundary between these tiers. - **Composability with prior advisories**: the recent fixes for `GHSA-rxf6-wjh4-jfj6` (cross-user trigger-task firing), `GHSA-hvv7-hfrh-7gxj` (WS server-stream cross-tenant leak), and `GHSA-4g6j-g789-rghm` (forged monitor results) all address the cross-tenant visibility model. This finding is a sibling that closes one more reader gap in the same model. ## Suggested fix Either of: 1. **Centralize the filter in `ServiceSentinel`** — change `Get(id)` and `GetSortedList()` to accept the `*gin.Context` (or a viewer context) and apply the `EnableShowInService` filter plus an admin-or-owner override. This guarantees every reader inherits the gate: ```go func (ss *ServiceSentinel) GetForViewer(c *gin.Context, id uint64) (*model.Service, bool) { s, ok := ss.Get(id) if !ok { return nil, false } if !s.EnableShowInService && !callerIsAdminOrOwns(c, s) { return nil, false } return s, true } ``` 2. **Recheck at every endpoint that emits service identity** — add the EnableShowInService + ownership check at the top of `listServerServices`, `getServiceHistory`, and anywhere else `GetSortedList()`/`Get()` results flow to a response. More surgical but easier to miss next time. Option (1) is symmetric with how `userCanViewServer` centralizes the server-visibility decision; the same pattern at the service layer would close this class once.

CVE-2026-49397 Affected Github.com/nezhahq/nezha Versions

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

References

Frequently Asked Questions

What is CVE-2026-49397?
CVE-2026-49397 is a security vulnerability in Github.com/nezhahq/nezha involving false`) are enumerable via per-server endpoints, leaking name and timing data under CWE-285: Improper Authorization.
Which Github.com/nezhahq/nezha versions are affected?
Affected versions include releases prior to 2.0.14 across affected packages: github.com/nezhahq/nezha.
What version fixes CVE-2026-49397?
Upgrading to 2.0.14 (or the latest patched release for your respective branch) resolves the vulnerability.
Is github.com/nezhahq/nezha affected?
Yes, github.com/nezhahq/nezha is affected in releases prior to 2.0.14. Upgrade your dependency to 2.0.14 or later.
What is the CVSS score of CVE-2026-49397?
CVE-2026-49397 has a CVSS v3.1 base score of 5.3 (MODERATE) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N.
What CWE is associated with CVE-2026-49397?
CVE-2026-49397 is classified under CWE-285: Improper Authorization (The product does not perform or incorrectly performs an authorization check when an actor attempts to access a resource or perform an action.).
How can I check Go dependencies for CVE-2026-49397?
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-vrmh-5mmx-hjwx",
  "modified" : "2026-06-26T21:29:08Z",
  "published" : "2026-06-10T13:39:24Z",
  "aliases" : [ "CVE-2026-49397" ],
  "summary" : "Nezha's private services (`EnableShowInService: false`) are enumerable via per-server endpoints, leaking name and timing data",
  "details" : "# Private services (`EnableShowInService: false`) are enumerable via per-server endpoints, leaking name and timing data\n\n**CWE**: CWE-285 (Improper Authorization) via CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor) and CWE-863 (Incorrect Authorization — inconsistent gating across data-reader paths)\n\n**CVSS v3.1**: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N` → 5.3 (Medium)\n\n## Summary\n\nThe `EnableShowInService` flag on a `Service` is meant to gate that service's visibility from the public dashboard. The main service-listing endpoint (`GET /api/v1/service` → `showService`) correctly filters services with `EnableShowInService: false` via `ServiceSentinel.CopyStats()` (`service/singleton/servicesentinel.go:421-438`). However, two adjacent reader endpoints retrieve service objects through code paths that do not honor the same flag:\n\n- `GET /api/v1/server/:id/service` (`listServerServices`) iterates `ServiceSentinel.GetSortedList()` (which returns every service regardless of visibility) and emits service ID, name, and timing data for any service monitoring the queried server.\n- `GET /api/v1/service/:id/history` (`getServiceHistory`) calls `ServiceSentinel.Get(serviceID)` directly and emits the service name (and aggregated per-server stats for servers the viewer can see).\n\nBoth endpoints are mounted on the `optionalAuth` group, so an unauthenticated visitor can enumerate hidden services as long as they can guess a public server ID (linear scan over a small numeric ID space) or a service ID (likewise). The service owner's intent — \"hide this from the public\" via `EnableShowInService: false` — is silently bypassed.\n\n## Affected\n\n- nezha `master` at HEAD `636f4a99e6c3d8d75f17fdf7ad55d4ee0f73f1c0` (the audit checkout)\n- All recent 2.x releases that share this code path (post the `EnableShowInService` filter introduction at `CopyStats`)\n\n## Vulnerability details\n\n### [A] — single-source-of-truth filter exists at the listing site\n\n`service/singleton/servicesentinel.go:421-438`:\n\n```go\nfunc (ss *ServiceSentinel) CopyStats() map[uint64]model.ServiceResponseItem {\n    var stats map[uint64]*serviceResponseItem\n    copier.Copy(&stats, ss.LoadStats())\n\n    sri := make(map[uint64]model.ServiceResponseItem)\n    for k, service := range stats {\n        if !service.service.EnableShowInService {       // [A] filter here\n            delete(stats, k)\n            continue\n        }\n        service.ServiceName = service.service.Name\n        sri[k] = service.ServiceResponseItem\n    }\n    return sri\n}\n```\n\n`CopyStats()` is the only reader that respects `EnableShowInService`. `Get()` and `GetSortedList()` immediately below it return the raw services with no such filter:\n\n```go\nfunc (ss *ServiceSentinel) Get(id uint64) (s *model.Service, ok bool) {\n    ss.servicesLock.RLock(); defer ss.servicesLock.RUnlock()\n    s, ok = ss.services[id]\n    return                                              // [A'] no EnableShowInService check\n}\n```\n\n### [B] — `listServerServices` iterates `GetSortedList()` and emits hidden services\n\n`cmd/dashboard/controller/service.go:258-340` (`GET /api/v1/server/:id/service`):\n\n```go\nfunc listServerServices(c *gin.Context) ([]*model.ServiceInfos, error) {\n    // ... server existence + userCanViewServer check ...\n    services := singleton.ServiceSentinelShared.GetSortedList()      // [B] all services, no filter\n\n    for _, service := range services {\n        if service.Cover == model.ServiceCoverAll {\n            if service.SkipServers[serverID] { continue }\n        } else {\n            if !service.SkipServers[serverID] { continue }\n        }\n        // ... fetch history ...\n        infos := &model.ServiceInfos{\n            ServiceID:   service.ID,\n            ServerID:    serverID,\n            ServiceName: service.Name,                  // [B'] leaked\n            ServerName:  server.Name,\n            // ... timing data ...\n        }\n        result = append(result, infos)\n    }\n    return result, nil\n}\n```\n\nThe DB-fallback path at `queryServerServicesFromDB` (`service.go:340-`) has the same structure: iterates `services` (the same `GetSortedList()` output) and emits ServiceName for any service monitoring `serverID`.\n\n### [C] — `getServiceHistory` returns the service name for any ID\n\n`cmd/dashboard/controller/service.go:126-180` (`GET /api/v1/service/:id/history`):\n\n```go\nfunc getServiceHistory(c *gin.Context) (*model.ServiceHistoryResponse, error) {\n    serviceID, _ := strconv.ParseUint(c.Param(\"id\"), 10, 64)\n    service, ok := singleton.ServiceSentinelShared.Get(serviceID)   // [C] no filter\n    if !ok || service == nil {\n        return nil, singleton.Localizer.ErrorT(\"service not found\")\n    }\n    // period restriction for guests (1d only) — but the service exists,\n    // and ServiceName is set unconditionally:\n    response := &model.ServiceHistoryResponse{\n        ServiceID:   serviceID,\n        ServiceName: service.Name,                       // [C'] leaked\n        Servers:     make([]model.ServerServiceStats, 0),\n    }\n    // ... per-server data is filtered via userCanViewServer — that part is correct ...\n    return response, nil\n}\n```\n\nThe per-server data inside the response IS correctly filtered via `userCanViewServer`. The service NAME is not.\n\n### The mismatch\n\n[A] (`CopyStats`) gates by `EnableShowInService` because that's the listing endpoint's contract. [A'] (`Get`) / `GetSortedList()` return the raw data because they're \"internal\" accessors. But [B] and [C] are public-reachable endpoints that use those raw accessors and emit identifying information about services the owner marked as private. The visibility flag exists; it just isn't enforced at every reader of the same data.\n\nA correct guard would either:\n- Move the `EnableShowInService` filter into `Get()` / `GetSortedList()` themselves, gated by \"caller is admin or service owner\"\n- Re-check `EnableShowInService` at every endpoint that emits service identity (name/id/timing)\n\n## Proof of concept\n\nSetup (any nezha 2.x deployment):\n1. User A (member) creates a Service \"Internal-CRM-Health\" with `EnableShowInService: false`, monitoring server `S` which is public (`HideForGuest: false`).\n2. The service does not appear in `GET /api/v1/service` (the main listing correctly hides it).\n\nEnumeration as an unauthenticated guest:\n\n```bash\n# Find services that monitor server S\ncurl -s 'https://nezha.example/api/v1/server/'\"$S_ID\"'/service'\n# →\n# {\"success\":true,\"data\":[\n#   {\"service_id\":42,\"server_id\":1,\"service_name\":\"Internal-CRM-Health\",\"server_name\":\"web-01\",\n#    \"display_index\":0,\"created_at\":[...],\"avg_delay\":[...]}\n# ]}\n#\n# Hidden service is leaked: ID, name, and per-server timing data are all visible.\n```\n\nConfirmation via the second endpoint:\n\n```bash\ncurl -s 'https://nezha.example/api/v1/service/42/history?period=1d'\n# →\n# {\"success\":true,\"data\":{\n#   \"service_id\":42,\n#   \"service_name\":\"Internal-CRM-Health\",  ← leaked even for direct ID lookup\n#   \"servers\":[]                            ← per-server data correctly hidden\n# }}\n```\n\nA scripted enumeration over public server IDs (a low-cardinality numeric space — typical nezha deployments have <1000 servers) trivially recovers the full set of hidden services that monitor any public server, along with their names and timing patterns.\n\n## Impact\n\n### Direct\n\nService names in nezha deployments are frequently descriptive of the underlying business asset they monitor: `\"Production CRM Monitor\"`, `\"Internal Wiki Health\"`, `\"Backup-Vault Connectivity\"`, `\"Stripe Webhook Latency\"`. The leak therefore:\n\n- **Discloses the existence and purpose of internal services** that the owner explicitly hid from the public dashboard.\n- **Exposes timing/latency data** for the monitored relationship between a private service and any public server it touches — sufficient for a competitor or attacker to infer business activity patterns, outage windows, and probable backend topology.\n- **Confirms presence/absence of a service ID** via the second endpoint — an oracle that lets an unauthenticated visitor enumerate the service-id namespace and learn the deployment's service count and naming convention even when no public servers exist as enumeration vectors.\n\n### Indirect / second-order\n\n- **Affects multi-tenant public dashboards**: nezha is frequently deployed as a public status page with a private \"internal\" tier in the same dashboard. The bypass collapses the privacy boundary between these tiers.\n- **Composability with prior advisories**: the recent fixes for `GHSA-rxf6-wjh4-jfj6` (cross-user trigger-task firing), `GHSA-hvv7-hfrh-7gxj` (WS server-stream cross-tenant leak), and `GHSA-4g6j-g789-rghm` (forged monitor results) all address the cross-tenant visibility model. This finding is a sibling that closes one more reader gap in the same model.\n\n## Suggested fix\n\nEither of:\n\n1. **Centralize the filter in `ServiceSentinel`** — change `Get(id)` and `GetSortedList()` to accept the `*gin.Context` (or a viewer context) and apply the `EnableShowInService` filter plus an admin-or-owner override. This guarantees every reader inherits the gate:\n\n   ```go\n   func (ss *ServiceSentinel) GetForViewer(c *gin.Context, id uint64) (*model.Service, bool) {\n       s, ok := ss.Get(id)\n       if !ok { return nil, false }\n       if !s.EnableShowInService && !callerIsAdminOrOwns(c, s) {\n           return nil, false\n       }\n       return s, true\n   }\n   ```\n\n2. **Recheck at every endpoint that emits service identity** — add the EnableShowInService + ownership check at the top of `listServerServices`, `getServiceHistory`, and anywhere else `GetSortedList()`/`Get()` results flow to a response. More surgical but easier to miss next time.\n\nOption (1) is symmetric with how `userCanViewServer` centralizes the server-visibility decision; the same pattern at the service layer would close this class once.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/nezhahq/nezha"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "2.0.0"
      }, {
        "fixed" : "2.0.14"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/nezhahq/nezha/security/advisories/GHSA-vrmh-5mmx-hjwx"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-49397"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/nezhahq/nezha"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-200", "CWE-285", "CWE-863" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-06-10T13:39:24Z",
    "nvd_published_at" : "2026-06-12T22:16:51Z"
  }
}