CVE-2026-49397: Github.com/nezhahq/nezha False`) Are Enumerable Vulnerability
MODERATECVE-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
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).
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.
Upgrade github.com/nezhahq/nezha to 2.0.14
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
CVE-2026-49397 Affected Github.com/nezhahq/nezha Versions
1 component(s)| 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?
Which Github.com/nezhahq/nezha versions are affected?
What version fixes CVE-2026-49397?
Is github.com/nezhahq/nezha affected?
What is the CVSS score of CVE-2026-49397?
What CWE is associated with CVE-2026-49397?
How can I check Go dependencies for CVE-2026-49397?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"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"
}
}