Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐹
🐹 Go
Not in CISA KEV
MEDIUM severity

CVE-2026-49397 — nezha

MEDIUM

CVE-2026-49397 is a medium-severity (CVSS 5.3) Information Exposure vulnerability in github.com/nezhahq/nezha. A fix is available for github.com/nezhahq/nezha — see the affected versions and patch details below.

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

Also known asGHSA-vrmh-5mmx-hjwxGO-2026-5672
Published
Updated
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Oct 22, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

Proof-of-concept exploit code exists

  • CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
  • CISA assesses this as automatable — exploitation doesn’t require manual, per-target effort, which raises the odds of mass scanning and opportunistic attacks.

Exploitation and automatability from CISA’s SSVC triage for CVE-2026-49397.

EPSS Exploitation Probability

via FIRST.org ↗
0.3%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs26th percentile — riskier than 26% of all scored CVEsHighest risk
0.00%0.28%0.56%0.84%0.3%0.3%0.3%0.3%0.3%Jul 26Sep 26Oct 26

Probability of exploitation in the next 30 days, from FIRST.org EPSS.

How urgent is this, really

CVE-2026-49397 by exploitation likelihood (EPSS) against impact (CVSS). Outside the shaded patch-first corner.

Where this sits among everything scored

Of 394,415 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Counts from FIRST.org, log-scaled.

Real-World Exposure

1 pkg affected
🐹github.com/nezhahq/nezha

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects Go packages — download data is not available via public APIs for these ecosystems.

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:

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

Affected Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
🐹Gogithub.com/nezhahq/nezha≥ 2.0.0&&< 2.0.142.0.14go get github.com/nezhahq/nezha@v2.0.14

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for github.com/nezhahq/nezha, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.

  2. Fix

    Update github.com/nezhahq/nezha to 2.0.14 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms CVE-2026-49397 is resolved across your whole dependency graph.

  3. Workarounds

    Put an independent control in front of the weakness: restrict the affected endpoint or interface to trusted networks, require an additional authentication factor or proxy-level check, and invalidate existing sessions and credentials in case the flaw has already been used.

Frequently Asked Questions

# 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`
O3 Security · Impact-Aware SCA

Is CVE-2026-49397 in your dependencies?

Find it across Go, including transitive dependencies.

CVE-2026-49397: nezha — Fixed in 2.0.14