PT-2026-48482 · Go · Github.Com/Nezhahq/Nezha

Published

2026-06-10

·

Updated

2026-06-10

·

CVE-2026-49397

CVSS v3.1

5.3

Medium

VectorAV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

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/serviceshowService) 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:
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), 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:
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
}
  1. 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.

Fix

Improper Authorization

Information Disclosure

Incorrect Authorization

Weakness Enumeration

Related Identifiers

CVE-2026-49397
GHSA-VRMH-5MMX-HJWX

Affected Products

Github.Com/Nezhahq/Nezha