From 94b7deef2478e036b257bf41ab2b76bb7c005767 Mon Sep 17 00:00:00 2001 From: naiba Date: Tue, 26 May 2026 03:59:59 +0000 Subject: [PATCH] fix(service): hide EnableShowInService=false services from sideband endpoints GHSA-vrmh-5mmx-hjwx: GET /api/v1/server/:id/service and GET /api/v1/service/:id/history both iterate the raw service list and emit ServiceName / timing for any service that happens to monitor the queried server, ignoring the owner's EnableShowInService=false flag. Both routes are on optionalAuth, so unauthenticated visitors could enumerate hidden services by name and timing. Introduce userCanViewService(c, service): - EnableShowInService=true -> always visible - admin -> always visible - authenticated owner -> visible (HasPermission) - everyone else -> hidden getServiceHistory rejects unknown-or-invisible service with the same 'service not found' message so the endpoint cannot be used as an oracle. listServerServices pre-filters the sorted service list. Owners and admins keep their existing visibility into their own hidden services. Tests: - TestUserCanViewServiceVisibleServiceIsPublic - TestUserCanViewServiceHiddenServiceRejectsGuest - TestUserCanViewServiceHiddenServiceRejectsForeignMember - TestUserCanViewServiceHiddenServiceAllowsOwner - TestUserCanViewServiceHiddenServiceAllowsAdmin Co-authored-by: cloudcode --- cmd/dashboard/controller/permissions.go | 16 +++++++ cmd/dashboard/controller/service.go | 11 +++-- .../controller/service_visibility_test.go | 48 +++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 cmd/dashboard/controller/service_visibility_test.go diff --git a/cmd/dashboard/controller/permissions.go b/cmd/dashboard/controller/permissions.go index 5a1d33cc..7c032de9 100644 --- a/cmd/dashboard/controller/permissions.go +++ b/cmd/dashboard/controller/permissions.go @@ -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 diff --git a/cmd/dashboard/controller/service.go b/cmd/dashboard/controller/service.go index b46cab11..96574822 100644 --- a/cmd/dashboard/controller/service.go +++ b/cmd/dashboard/controller/service.go @@ -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 diff --git a/cmd/dashboard/controller/service_visibility_test.go b/cmd/dashboard/controller/service_visibility_test.go new file mode 100644 index 00000000..729b2d12 --- /dev/null +++ b/cmd/dashboard/controller/service_visibility_test.go @@ -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") +}