GHSA-VRMH-5MMX-HJWX
Vulnerability from github – Published: 2026-06-10 13:39 – Updated: 2026-06-10 13:39Private 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) iteratesServiceSentinel.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) callsServiceSentinel.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
masterat HEAD636f4a99e6c3d8d75f17fdf7ad55d4ee0f73f1c0(the audit checkout) - All recent 2.x releases that share this code path (post the
EnableShowInServicefilter introduction atCopyStats)
Vulnerability details
[A] — single-source-of-truth filter exists at the listing site
service/singleton/servicesentinel.go:421-438:
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:
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):
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):
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:
# 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:
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), andGHSA-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:
- Centralize the filter in
ServiceSentinel— changeGet(id)andGetSortedList()to accept the*gin.Context(or a viewer context) and apply theEnableShowInServicefilter 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
}
- Recheck at every endpoint that emits service identity — add the EnableShowInService + ownership check at the top of
listServerServices,getServiceHistory, and anywhere elseGetSortedList()/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.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/nezhahq/nezha"
},
"ranges": [
{
"events": [
{
"introduced": "2.0.0"
},
{
"fixed": "2.0.14"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-49397"
],
"database_specific": {
"cwe_ids": [
"CWE-200",
"CWE-285",
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-10T13:39:24Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"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 \u2014 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` \u2192 5.3 (Medium)\n\n## Summary\n\nThe `EnableShowInService` flag on a `Service` is meant to gate that service\u0027s visibility from the public dashboard. The main service-listing endpoint (`GET /api/v1/service` \u2192 `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\u0027s intent \u2014 \"hide this from the public\" via `EnableShowInService: false` \u2014 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] \u2014 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(\u0026stats, 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\u0027] no EnableShowInService check\n}\n```\n\n### [B] \u2014 `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 := \u0026model.ServiceInfos{\n ServiceID: service.ID,\n ServerID: serverID,\n ServiceName: service.Name, // [B\u0027] 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] \u2014 `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) \u2014 but the service exists,\n // and ServiceName is set unconditionally:\n response := \u0026model.ServiceHistoryResponse{\n ServiceID: serviceID,\n ServiceName: service.Name, // [C\u0027] leaked\n Servers: make([]model.ServerServiceStats, 0),\n }\n // ... per-server data is filtered via userCanViewServer \u2014 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\u0027s the listing endpoint\u0027s contract. [A\u0027] (`Get`) / `GetSortedList()` return the raw data because they\u0027re \"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\u0027t 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 \u0027https://nezha.example/api/v1/server/\u0027\"$S_ID\"\u0027/service\u0027\n# \u2192\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 \u0027https://nezha.example/api/v1/service/42/history?period=1d\u0027\n# \u2192\n# {\"success\":true,\"data\":{\n# \"service_id\":42,\n# \"service_name\":\"Internal-CRM-Health\", \u2190 leaked even for direct ID lookup\n# \"servers\":[] \u2190 per-server data correctly hidden\n# }}\n```\n\nA scripted enumeration over public server IDs (a low-cardinality numeric space \u2014 typical nezha deployments have \u003c1000 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 \u2014 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 \u2014 an oracle that lets an unauthenticated visitor enumerate the service-id namespace and learn the deployment\u0027s 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`** \u2014 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 \u0026\u0026 !callerIsAdminOrOwns(c, s) {\n return nil, false\n }\n return s, true\n }\n ```\n\n2. **Recheck at every endpoint that emits service identity** \u2014 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.",
"id": "GHSA-vrmh-5mmx-hjwx",
"modified": "2026-06-10T13:39:24Z",
"published": "2026-06-10T13:39:24Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/nezhahq/nezha/security/advisories/GHSA-vrmh-5mmx-hjwx"
},
{
"type": "PACKAGE",
"url": "https://github.com/nezhahq/nezha"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Nezha\u0027s private services (`EnableShowInService: false`) are enumerable via per-server endpoints, leaking name and timing data"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.