GitHub Advisoryadvisory · Go
GHSA-vrmh-5mmx-hjwx Nezha's private services (`EnableShowInService: false`) are enumerable via per-server endpoints, leaking name and timing data
MEDIUM
Official page ↗
- Aliases
- CVE-2026-49397
- Ecosystem
- Go
- Packages
- github.com/nezhahq/nezha
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N
- Weaknesses
- CWE-200, CWE-285, CWE-863
- Reviewed
- 2026-06-10
Affected packages
• Go github.com/nezhahq/nezha: >= 2.0.0, < 2.0.14 → fixed in 2.0.14
Description
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 …
Related
Alias · CVE (1)
- CVE-2026-49397 Nezha Monitoring is a self-hostable, lightweight, servers and websites monitoring and O&M tool. vulnerability