GHSA-vrmh-5mmx-hjwx
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
**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.
Are you affected?
Enter the version of the package you're using.
Affected packages
2.0.0 Fixed in: 2.0.14 go get github.com/nezhahq/nezha@v2.0.14