{"id":"CVE-2026-49397","aliases":["GHSA-vrmh-5mmx-hjwx"],"title":"Nezha's private services (`EnableShowInService: false`) are enumerable via per-server endpoints, leaking name and timing data","summary":"Nezha's private services (`EnableShowInService: false`) are enumerable via per-server endpoints, leaking name and timing data","severity":"medium","cvss":5.3,"cwe":["CWE-200","CWE-285","CWE-863"],"vendor":"nezhahq","product":"github.com/nezhahq/nezha","ecosystem":"go","affected":["github.com/nezhahq/nezha >= 2.0.0, < 2.0.14"],"patched":["github.com/nezhahq/nezha 2.0.14"],"published":"2026-06-10","updated":"2026-06-26","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-vrmh-5mmx-hjwx","references":[{"url":"https://github.com/nezhahq/nezha/security/advisories/GHSA-vrmh-5mmx-hjwx"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-49397"},{"url":"https://github.com/advisories/GHSA-vrmh-5mmx-hjwx"}],"tags":["ghsa","go"],"epss":0.00253,"epssPercentile":0.1707,"ingestedAt":"2026-07-07T15:41:59.564Z","slug":"CVE-2026-49397","body":"## Overview\n\n# 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.\n\n## Affected packages\n\n- `github.com/nezhahq/nezha >= 2.0.0, < 2.0.14`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `github.com/nezhahq/nezha 2.0.14`","depth":"sunlit","depthScore":29,"depthScoreParts":{"impact":29.2,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}