From c94639e1996c93bde3f5dd08d4622a29dbcb2a32 Mon Sep 17 00:00:00 2001 From: naiba Date: Tue, 26 May 2026 04:07:50 +0000 Subject: [PATCH] fix(server-group): hide guest-empty groups from guest listing listServerGroup returned every group, including ones whose only servers are HideForGuest=true (or that have zero members), to unauthenticated callers. The group name itself is then leaked to guests even though every server it references is hidden from them. Guest visitors now only see groups that contain at least one guest- visible server. Authenticated members and admins keep full visibility (including their own empty groups) so management UI still works. Co-authored-by: cloudcode --- cmd/dashboard/controller/server_group.go | 3 + .../server_group_visibility_test.go | 122 ++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 cmd/dashboard/controller/server_group_visibility_test.go diff --git a/cmd/dashboard/controller/server_group.go b/cmd/dashboard/controller/server_group.go index bcf11f15..4947aeee 100644 --- a/cmd/dashboard/controller/server_group.go +++ b/cmd/dashboard/controller/server_group.go @@ -58,6 +58,9 @@ func listServerGroup(c *gin.Context) ([]*model.ServerGroupResponseItem, error) { if isMember && !isAdmin && !s.HasPermission(c) { continue } + if !isMember && len(groupServers[s.ID]) == 0 { + continue + } sgRes = append(sgRes, &model.ServerGroupResponseItem{ Group: s, Servers: groupServers[s.ID], diff --git a/cmd/dashboard/controller/server_group_visibility_test.go b/cmd/dashboard/controller/server_group_visibility_test.go new file mode 100644 index 00000000..be5803f4 --- /dev/null +++ b/cmd/dashboard/controller/server_group_visibility_test.go @@ -0,0 +1,122 @@ +package controller + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/patrickmn/go-cache" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/nezhahq/nezha/model" + "github.com/nezhahq/nezha/pkg/i18n" + "github.com/nezhahq/nezha/service/singleton" +) + +func setupServerGroupVisibilityFixture(t *testing.T) { + t.Helper() + + originalDB := singleton.DB + originalCache := singleton.Cache + originalLoc := singleton.Loc + originalLocalizer := singleton.Localizer + originalServer := singleton.ServerShared + originalUserInfo := singleton.UserInfoMap + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Server{}, &model.ServerGroup{}, &model.ServerGroupServer{}, &model.User{})) + + singleton.DB = db + singleton.Loc = time.UTC + singleton.Cache = cache.New(time.Minute, time.Minute) + singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations) + singleton.UserLock.Lock() + singleton.UserInfoMap = map[uint64]model.UserInfo{ + 1: {Role: model.RoleAdmin}, + 200: {Role: model.RoleMember}, + } + singleton.UserLock.Unlock() + + require.NoError(t, db.Create(&model.Server{Common: model.Common{ID: 1, UserID: 1}, Name: "public", UUID: "public", HideForGuest: false}).Error) + require.NoError(t, db.Create(&model.Server{Common: model.Common{ID: 2, UserID: 1}, Name: "hidden", UUID: "hidden", HideForGuest: true}).Error) + + require.NoError(t, db.Create(&model.ServerGroup{Common: model.Common{ID: 10, UserID: 1}, Name: "Public Group"}).Error) + require.NoError(t, db.Create(&model.ServerGroup{Common: model.Common{ID: 11, UserID: 1}, Name: "Empty Group"}).Error) + require.NoError(t, db.Create(&model.ServerGroupServer{Common: model.Common{UserID: 1}, ServerGroupId: 10, ServerId: 1}).Error) + + singleton.ServerShared = singleton.NewServerClass() + + t.Cleanup(func() { + singleton.DB = originalDB + singleton.Cache = originalCache + singleton.Loc = originalLoc + singleton.Localizer = originalLocalizer + singleton.ServerShared = originalServer + singleton.UserLock.Lock() + singleton.UserInfoMap = originalUserInfo + singleton.UserLock.Unlock() + }) +} + +func newServerGroupCtx(viewer *model.User) *gin.Context { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("GET", "/api/v1/server-group", nil) + if viewer != nil { + c.Set(model.CtxKeyAuthorizedUser, viewer) + } + return c +} + +func collectGroupNames(items []*model.ServerGroupResponseItem) []string { + names := make([]string, 0, len(items)) + for _, it := range items { + names = append(names, it.Group.Name) + } + return names +} + +func TestListServerGroupGuestSkipsGroupsWithoutVisibleServers(t *testing.T) { + setupServerGroupVisibilityFixture(t) + + items, err := listServerGroup(newServerGroupCtx(nil)) + require.NoError(t, err) + names := collectGroupNames(items) + + assert.ElementsMatch(t, []string{"Public Group"}, names, + "a group with no guest-visible servers is meaningless to a guest UI and exposing its name leaks the existence of empty/hidden-only groups") +} + +func TestListServerGroupAuthenticatedMemberSeesOwnEmptyGroup(t *testing.T) { + setupServerGroupVisibilityFixture(t) + + require.NoError(t, singleton.DB.Create(&model.ServerGroup{Common: model.Common{ID: 12, UserID: 200}, Name: "member empty group"}).Error) + + items, err := listServerGroup(newServerGroupCtx(&model.User{ + Common: model.Common{ID: 200}, + Role: model.RoleMember, + })) + require.NoError(t, err) + names := collectGroupNames(items) + + assert.Contains(t, names, "member empty group", "owner must still see their own empty group") +} + +func TestListServerGroupAdminSeesAllGroupsIncludingEmpty(t *testing.T) { + setupServerGroupVisibilityFixture(t) + + items, err := listServerGroup(newServerGroupCtx(&model.User{ + Common: model.Common{ID: 1}, + Role: model.RoleAdmin, + })) + require.NoError(t, err) + names := collectGroupNames(items) + + assert.ElementsMatch(t, []string{"Public Group", "Empty Group"}, names, + "admin must keep full visibility, including empty groups") +}