OSV 1.4.0 · github-reviewed · 修改于 2026-06-27 05:29
发布时间
2026-06-10 21:39
GitHub 审查时间
2026-06-10 21:39
NVD 发布时间
2026-06-13 06:16
源文件
advisories/github-reviewed/2026/06/GHSA-vrmh-5mmx-hjwx/GHSA-vrmh-5mmx-hjwx.json
EnableShowInService: false) are enumerable via per-server endpoints, leaking name and timing dataCWE: 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)
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.
master at HEAD 636f4a99e6c3d8d75f17fdf7ad55d4ee0f73f1c0 (the audit checkout)EnableShowInService filter introduction at CopyStats)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
}
listServerServices iterates GetSortedList() and emits hidden servicescmd/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.
getServiceHistory returns the service name for any IDcmd/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.
[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:
EnableShowInService filter into Get() / GetSortedList() themselves, gated by "caller is admin or service owner"EnableShowInService at every endpoint that emits service identity (name/id/timing)Setup (any nezha 2.x deployment):
EnableShowInService: false, monitoring server S which is public (HideForGuest: false).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.
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:
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.Either of:
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
}
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.