VYPR
Medium severity5.3NVD Advisory· Published Jun 10, 2026

Nezha's private services (`EnableShowInService: false`) are enumerable via per-server endpoints, leaking name and timing data

CVE-2026-49397

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/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.

Affected products

1
  • Nezhahq/Nezhallm-fuzzy
    Range: master at HEAD `636f4a99e6c3d8d75f17fdf7ad55d4d99f1c0`

Patches

1
0fbe48d9950e

fix(service): hide EnableShowInService=false services from sideband endpoints

https://github.com/nezhahq/nezhaNezha DevMay 26, 2026Fixed in 2.0.14via ghsa-release-walk
3 files changed · +72 3
  • cmd/dashboard/controller/permissions.go+16 0 modified
    @@ -35,6 +35,22 @@ func userCanViewServer(c *gin.Context, server *model.Server) bool {
     	return !server.HideForGuest
     }
     
    +func userCanViewService(c *gin.Context, service *model.Service) bool {
    +	if service == nil {
    +		return false
    +	}
    +	if service.EnableShowInService {
    +		return true
    +	}
    +	if callerIsAdmin(c) {
    +		return true
    +	}
    +	if _, isMember := c.Get(model.CtxKeyAuthorizedUser); isMember {
    +		return service.HasPermission(c)
    +	}
    +	return false
    +}
    +
     func assertOwnsNotificationGroup(c *gin.Context, groupID uint64) error {
     	if groupID == 0 {
     		return nil
    
  • cmd/dashboard/controller/service.go+8 3 modified
    @@ -130,9 +130,8 @@ func getServiceHistory(c *gin.Context) (*model.ServiceHistoryResponse, error) {
     		return nil, err
     	}
     
    -	// 检查服务是否存在
     	service, ok := singleton.ServiceSentinelShared.Get(serviceID)
    -	if !ok || service == nil {
    +	if !ok || service == nil || !userCanViewService(c, service) {
     		return nil, singleton.Localizer.ErrorT("service not found")
     	}
     
    @@ -285,7 +284,13 @@ func listServerServices(c *gin.Context) ([]*model.ServiceInfos, error) {
     		return nil, singleton.Localizer.ErrorT("unauthorized: only 1d data available for guests")
     	}
     
    -	services := singleton.ServiceSentinelShared.GetSortedList()
    +	allServices := singleton.ServiceSentinelShared.GetSortedList()
    +	services := make([]*model.Service, 0, len(allServices))
    +	for _, s := range allServices {
    +		if userCanViewService(c, s) {
    +			services = append(services, s)
    +		}
    +	}
     
     	var result []*model.ServiceInfos
     
    
  • cmd/dashboard/controller/service_visibility_test.go+48 0 added
    @@ -0,0 +1,48 @@
    +package controller
    +
    +import (
    +	"net/http/httptest"
    +	"testing"
    +
    +	"github.com/gin-gonic/gin"
    +	"github.com/stretchr/testify/assert"
    +
    +	"github.com/nezhahq/nezha/model"
    +)
    +
    +func newServiceVisibilityCtx(viewer *model.User) *gin.Context {
    +	gin.SetMode(gin.TestMode)
    +	c, _ := gin.CreateTestContext(httptest.NewRecorder())
    +	if viewer != nil {
    +		c.Set(model.CtxKeyAuthorizedUser, viewer)
    +	}
    +	return c
    +}
    +
    +func TestUserCanViewServiceVisibleServiceIsPublic(t *testing.T) {
    +	visible := &model.Service{Common: model.Common{ID: 1, UserID: 100}, EnableShowInService: true}
    +	assert.True(t, userCanViewService(newServiceVisibilityCtx(nil), visible), "guest must see EnableShowInService=true regardless of owner")
    +}
    +
    +func TestUserCanViewServiceHiddenServiceRejectsGuest(t *testing.T) {
    +	hidden := &model.Service{Common: model.Common{ID: 1, UserID: 100}, EnableShowInService: false}
    +	assert.False(t, userCanViewService(newServiceVisibilityCtx(nil), hidden), "guest must NOT see hidden service via per-server / per-id sideband endpoints")
    +}
    +
    +func TestUserCanViewServiceHiddenServiceRejectsForeignMember(t *testing.T) {
    +	hidden := &model.Service{Common: model.Common{ID: 1, UserID: 100}, EnableShowInService: false}
    +	foreign := &model.User{Common: model.Common{ID: 200}, Role: model.RoleMember}
    +	assert.False(t, userCanViewService(newServiceVisibilityCtx(foreign), hidden), "foreign member must NOT see another user's hidden service")
    +}
    +
    +func TestUserCanViewServiceHiddenServiceAllowsOwner(t *testing.T) {
    +	hidden := &model.Service{Common: model.Common{ID: 1, UserID: 100}, EnableShowInService: false}
    +	owner := &model.User{Common: model.Common{ID: 100}, Role: model.RoleMember}
    +	assert.True(t, userCanViewService(newServiceVisibilityCtx(owner), hidden), "owner must still see their own hidden service")
    +}
    +
    +func TestUserCanViewServiceHiddenServiceAllowsAdmin(t *testing.T) {
    +	hidden := &model.Service{Common: model.Common{ID: 1, UserID: 100}, EnableShowInService: false}
    +	admin := &model.User{Common: model.Common{ID: 1}, Role: model.RoleAdmin}
    +	assert.True(t, userCanViewService(newServiceVisibilityCtx(admin), hidden), "admin must be able to see any hidden service")
    +}
    

Vulnerability mechanics

Root cause

"The `EnableShowInService` flag is not consistently enforced across all service data retrieval endpoints."

Attack vector

An unauthenticated attacker can enumerate private services by guessing public server IDs or service IDs. The `GET /api/v1/server/:id/service` endpoint iterates through all services using `ServiceSentinel.GetSortedList()` [ref_id=1], which does not filter based on the `EnableShowInService` flag. This allows the attacker to discover the IDs and names of services that were intended to be hidden. Similarly, the `GET /api/v1/service/:id/history` endpoint directly calls `ServiceSentinel.Get(serviceID)` [ref_id=1], bypassing the visibility check and leaking the service name.

Affected code

The vulnerability lies in the `ServiceSentinel.Get()` and `ServiceSentinel.GetSortedList()` functions within `service/singleton/servicesentinel.go`, which do not enforce the `EnableShowInService` flag [ref_id=1]. Specifically, the `listServerServices` function in `cmd/dashboard/controller/service.go` calls `GetSortedList()` [ref_id=1], and the `getServiceHistory` function calls `Get()` [ref_id=1], both without applying the necessary visibility checks.

What the fix does

The suggested fix involves centralizing the `EnableShowInService` filter within the `ServiceSentinel`'s data retrieval methods, such as `Get()` and `GetSortedList()`. These methods should be modified to accept a viewer context and apply the `EnableShowInService` check, potentially with an admin or owner override. This ensures that all endpoints consuming service data will respect the visibility settings, preventing unauthorized disclosure of private service information.

Preconditions

  • configA service must be configured with `EnableShowInService: false`.
  • networkThe attacker must be able to reach the `/api/v1/server/:id/service` or `/api/v1/service/:id/history` endpoints.
  • inputThe attacker needs to guess a valid server ID or service ID to enumerate hidden services.

Reproduction

1. Create a service with `EnableShowInService: false` that monitors a public server. 2. As an unauthenticated guest, query `GET /api/v1/server/:id/service` with the public server's ID to enumerate the hidden service's name and timing data. 3. Query `GET /api/v1/service/:id/history` with the hidden service's ID to confirm the service name is leaked [ref_id=1].

Generated on Jun 10, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.