Skip to main content

nezha CVE-2026-49397

MEDIUM
Information Exposure (CWE-200)
2026-06-10 https://github.com/nezhahq/nezha GHSA-vrmh-5mmx-hjwx
5.3
CVSS 3.1 · Vendor: https://github.com/nezhahq/nezha
Share

Severity by source

Vendor (https://github.com/nezhahq/nezha) PRIMARY
5.3 MEDIUM
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

Primary rating from Vendor (https://github.com/nezhahq/nezha) · only source for this CVE.

CVSS VectorVendor: https://github.com/nezhahq/nezha

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
None
Availability
None

Lifecycle Timeline

3
Source Code Evidence Fetched
Jun 10, 2026 - 14:35 vuln.today
Analysis Generated
Jun 10, 2026 - 14:35 vuln.today
CVE Published
Jun 10, 2026 - 13:39 nvd
MEDIUM 5.3

DescriptionCVE.org

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:

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

AnalysisAI

Information disclosure in nezha 2.x server monitoring dashboard exposes private services marked with EnableShowInService: false to unauthenticated network visitors through two API endpoints that bypass the intended visibility filter. Attackers who can reach the API can enumerate hidden service names, IDs, and per-server timing data by iterating over public server IDs or guessing numeric service IDs - both low-cardinality spaces in typical deployments. No public exploit is required to leverage this; a fully functional proof-of-concept using only standard curl commands is documented in the GitHub security advisory GHSA-vrmh-5mmx-hjwx, and the fix is available in version 2.0.14.

Technical ContextAI

Nezha is a Go-based open-source server monitoring and public status-page platform (pkg:go/github.com/nezhahq/nezha). Its internal ServiceSentinel singleton manages service state and exposes several accessor methods: CopyStats(), Get(id), and GetSortedList(). The EnableShowInService visibility flag is only enforced in CopyStats() (servicesentinel.go:421-438), which is the path behind the main public listing endpoint. However, Get() and GetSortedList() return raw, unfiltered service objects. Two routes in the optionalAuth middleware group - GET /api/v1/server/:id/service (listServerServices, service.go:258-340) and GET /api/v1/service/:id/history (getServiceHistory, service.go:126-180) - consume these unfiltered accessors and emit service identity data (name, ID, timing) in their responses without re-checking EnableShowInService. The root cause class is CWE-200 (Exposure of Sensitive Information), with the deeper structural issue being CWE-285/CWE-863: the authorization gate is implemented as a single point in one accessor rather than enforced consistently across all data-reader paths that emit sensitive service identity to external callers.

RemediationAI

Upgrade nezha to version 2.0.14 or later, which is the vendor-released patch confirmed by the GitHub advisory and package metadata. The fix closes the inconsistent EnableShowInService enforcement gap across the listServerServices and getServiceHistory endpoints. Full advisory details are at https://github.com/nezhahq/nezha/security/advisories/GHSA-vrmh-5mmx-hjwx. If immediate upgrade is not possible, a compensating control is to place the nezha API behind a reverse proxy with authentication enforcement on the two affected routes (/api/v1/server/:id/service and /api/v1/service/:id/history), preventing unauthenticated access; this trade-off is that it breaks legitimate unauthenticated use of those endpoints for public status pages and must be reversed once patched. There is no configuration-only mitigation within nezha itself: setting all services to EnableShowInService: true eliminates the disclosure but defeats the privacy feature entirely.

CVE-2026-45321 CRITICAL POC
9.6 May 12

Credential-harvesting malware compromised 84 versions of 42 TanStack npm packages on 2026-05-11 via chained GitHub Actio

CVE-2021-30476 CRITICAL POC
9.8 Apr 22

HashiCorp Terraform’s Vault Provider (terraform-provider-vault) did not correctly configure GCE-type bound labels for Va

CVE-2019-7442 CRITICAL POC
9.8 May 08

An XML external entity (XXE) vulnerability in the Password Vault Web Access (PVWA) of CyberArk Enterprise Password Vault

CVE-2018-20371 CRITICAL POC
9.8 Dec 23

PhotoRange Photo Vault 1.2 appends the password to the URI for authorization, which makes it easier for remote attackers

CVE-2018-9843 CRITICAL POC
9.8 Apr 12

The REST API in CyberArk Password Vault Web Access before 9.9.5 and 10.x before 10.1 allows remote attackers to execute

CVE-2026-60104 CRITICAL POC
9.3 Jul 08

Account takeover in self-hosted Bitwarden Server before 2026.6.0 lets a low-privileged organization member steal any oth

CVE-2021-43837 CRITICAL POC
9.1 Dec 16

vault-cli is a configurable command-line interface tool (and python library) to interact with Hashicorp Vault. Rated cri

CVE-2020-16272 CRITICAL POC
9.1 Aug 03

The SRP-6a implementation in Kee Vault KeePassRPC before 1.12.0 is missing validation for a client-provided parameter, w

CVE-2020-16271 CRITICAL POC
9.1 Aug 03

The SRP-6a implementation in Kee Vault KeePassRPC before 1.12.0 generates insufficiently random numbers, which allows re

CVE-2017-11741 HIGH POC
8.8 Aug 08

HashiCorp Vagrant VMware Fusion plugin (aka vagrant-vmware-fusion) before 4.0.24 uses weak permissions for the sudo help

CVE-2024-52009 HIGH POC
8.5 Nov 08

Atlantis is a self-hosted golang application that listens for Terraform pull request events via webhooks. Rated high sev

CVE-2025-58437 HIGH POC
8.1 Sep 06

Coder allows organizations to provision remote development environments via Terraform. Rated high severity (CVSS 8.1), t

Share

CVE-2026-49397 vulnerability details – vuln.today

This site uses cookies essential for authentication and security. No tracking or analytics cookies are used. Privacy Policy