diff --git a/cmd/dashboard/controller/ws.go b/cmd/dashboard/controller/ws.go index 0efb3c73..e8c52213 100644 --- a/cmd/dashboard/controller/ws.go +++ b/cmd/dashboard/controller/ws.go @@ -121,9 +121,14 @@ func serverStream(c *gin.Context) (any, error) { } u, isMember := c.Get(model.CtxKeyAuthorizedUser) - var userId uint64 + var ( + userId uint64 + isAdmin bool + ) if isMember { - userId = u.(*model.User).ID + user := u.(*model.User) + userId = user.ID + isAdmin = user.Role.IsAdmin() } singleton.AddOnlineUser(connId, &model.OnlineUser{ @@ -136,7 +141,7 @@ func serverStream(c *gin.Context) (any, error) { count := 0 for { - stat, err := getServerStat(count == 0, isMember) + stat, err := getServerStat(count == 0, userId, isAdmin) if err != nil { continue } @@ -157,33 +162,18 @@ func serverStream(c *gin.Context) (any, error) { var requestGroup singleflight.Group -func getServerStat(withPublicNote, authorized bool) ([]byte, error) { - v, err, _ := requestGroup.Do(fmt.Sprintf("serverStats::%t", authorized), func() (any, error) { - var serverList []*model.Server - if authorized { - serverList = singleton.ServerShared.GetSortedList() - } else { - serverList = singleton.ServerShared.GetSortedListForGuest() - } - - servers := make([]model.StreamServer, 0, len(serverList)) - for _, server := range serverList { - var countryCode string - if server.GeoIP != nil { - countryCode = server.GeoIP.CountryCode - } - servers = append(servers, model.StreamServer{ - ID: server.ID, - Name: server.Name, - PublicNote: utils.IfOr(withPublicNote, server.PublicNote, ""), - DisplayIndex: server.DisplayIndex, - Host: utils.IfOr(authorized, server.Host, server.Host.Filter()), - State: server.State, - CountryCode: countryCode, - LastActive: server.LastActive, - }) - } - +// getServerStat returns the websocket frame the viewer is allowed to see. +// The cache key must include the viewer's identity because the projection +// depends on per-server ownership: prior to GHSA-hvv7-hfrh-7gxj this function +// used a single isMember flag and leaked HideForGuest servers plus full Host +// (PlatformVersion, agent Version, GPU) to every authenticated user. +func getServerStat(withPublicNote bool, viewerUserID uint64, viewerIsAdmin bool) ([]byte, error) { + cacheKey := fmt.Sprintf("serverStats::%t::%t::%d", withPublicNote, viewerIsAdmin, viewerUserID) + v, err, _ := requestGroup.Do(cacheKey, func() (any, error) { + servers := filterServersForViewer( + singleton.ServerShared.GetSortedList(), + viewerUserID, viewerIsAdmin, withPublicNote, + ) return json.Marshal(model.StreamServerData{ Now: time.Now().Unix() * 1000, Online: singleton.GetOnlineUserCount(), @@ -193,3 +183,36 @@ func getServerStat(withPublicNote, authorized bool) ([]byte, error) { return v.([]byte), err } + +// filterServersForViewer projects the global server list down to what a single +// viewer is allowed to see. The rules are: +// - HideForGuest servers are visible only to their owner and to admins. +// - Non-owner / non-admin viewers (including authenticated members) get +// Host.Filter() output, which drops PlatformVersion and agent Version. +// - Admins are unconstrained. +// +// viewerUserID == 0 represents an unauthenticated guest. +func filterServersForViewer(servers []*model.Server, viewerUserID uint64, viewerIsAdmin bool, withPublicNote bool) []model.StreamServer { + out := make([]model.StreamServer, 0, len(servers)) + for _, server := range servers { + isOwnerOrAdmin := viewerIsAdmin || (viewerUserID != 0 && server.UserID == viewerUserID) + if server.HideForGuest && !isOwnerOrAdmin { + continue + } + var countryCode string + if server.GeoIP != nil { + countryCode = server.GeoIP.CountryCode + } + out = append(out, model.StreamServer{ + ID: server.ID, + Name: server.Name, + PublicNote: utils.IfOr(withPublicNote, server.PublicNote, ""), + DisplayIndex: server.DisplayIndex, + Host: utils.IfOr(isOwnerOrAdmin, server.Host, server.Host.Filter()), + State: server.State, + CountryCode: countryCode, + LastActive: server.LastActive, + }) + } + return out +} diff --git a/cmd/dashboard/controller/ws_stream_visibility_test.go b/cmd/dashboard/controller/ws_stream_visibility_test.go new file mode 100644 index 00000000..9c006582 --- /dev/null +++ b/cmd/dashboard/controller/ws_stream_visibility_test.go @@ -0,0 +1,157 @@ +package controller + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/nezhahq/nezha/model" +) + +func makeStreamTestServers() []*model.Server { + return []*model.Server{ + { + Common: model.Common{ID: 1, UserID: 100}, + Name: "alice-public", + PublicNote: "alice-public-note", + DisplayIndex: 0, + HideForGuest: false, + Host: &model.Host{ + Platform: "linux", PlatformVersion: "6.1", + CPU: []string{"amd64"}, Version: "agent-v1", GPU: []string{"rtx"}, + }, + State: &model.HostState{CPU: 0.1}, + LastActive: time.Unix(1_700_000_000, 0).UTC(), + }, + { + Common: model.Common{ID: 2, UserID: 100}, + Name: "alice-hidden", + PublicNote: "alice-hidden-note", + DisplayIndex: 0, + HideForGuest: true, + Host: &model.Host{ + Platform: "linux", PlatformVersion: "6.5", + CPU: []string{"amd64"}, Version: "agent-v2", GPU: []string{"rtx"}, + }, + State: &model.HostState{CPU: 0.2}, + LastActive: time.Unix(1_700_000_001, 0).UTC(), + }, + { + Common: model.Common{ID: 3, UserID: 200}, + Name: "bob-public", + PublicNote: "bob-public-note", + DisplayIndex: 0, + HideForGuest: false, + Host: &model.Host{ + Platform: "darwin", PlatformVersion: "14.0", + CPU: []string{"arm64"}, Version: "agent-v3", GPU: []string{"m2"}, + }, + State: &model.HostState{CPU: 0.3}, + LastActive: time.Unix(1_700_000_002, 0).UTC(), + }, + { + Common: model.Common{ID: 4, UserID: 200}, + Name: "bob-hidden", + PublicNote: "bob-hidden-note", + DisplayIndex: 0, + HideForGuest: true, + Host: &model.Host{ + Platform: "darwin", PlatformVersion: "14.1", + CPU: []string{"arm64"}, Version: "agent-v4", GPU: []string{"m2"}, + }, + State: &model.HostState{CPU: 0.4}, + LastActive: time.Unix(1_700_000_003, 0).UTC(), + }, + } +} + +func findStreamServer(out []model.StreamServer, id uint64) *model.StreamServer { + for i := range out { + if out[i].ID == id { + return &out[i] + } + } + return nil +} + +// Guest: no auth → skip every HideForGuest server, Host.Filter() drops +// PlatformVersion and agent Version while keeping the rest (including GPU). +func TestFilterServersForViewerGuestHidesPrivateAndRedactsHost(t *testing.T) { + out := filterServersForViewer(makeStreamTestServers(), 0, false, true) + + assert.Len(t, out, 2) + assert.Nil(t, findStreamServer(out, 2), "alice-hidden should be invisible to guests") + assert.Nil(t, findStreamServer(out, 4), "bob-hidden should be invisible to guests") + + alicePublic := findStreamServer(out, 1) + if assert.NotNil(t, alicePublic) { + assert.Empty(t, alicePublic.Host.PlatformVersion, "guest must not see PlatformVersion") + assert.Empty(t, alicePublic.Host.Version, "guest must not see agent Version") + assert.Equal(t, "linux", alicePublic.Host.Platform, "non-sensitive Platform stays visible") + assert.Equal(t, "alice-public-note", alicePublic.PublicNote) + } +} + +// Non-owner member must see exactly the same data as a guest: +// no HideForGuest servers, and Host details on visible servers are redacted. +func TestFilterServersForViewerNonOwnerMemberMatchesGuest(t *testing.T) { + servers := makeStreamTestServers() + carolID := uint64(300) + out := filterServersForViewer(servers, carolID, false, true) + + assert.Len(t, out, 2) + assert.Nil(t, findStreamServer(out, 2)) + assert.Nil(t, findStreamServer(out, 4)) + + bobPublic := findStreamServer(out, 3) + if assert.NotNil(t, bobPublic) { + assert.Empty(t, bobPublic.Host.PlatformVersion) + assert.Empty(t, bobPublic.Host.Version) + } +} + +// Owner member: sees own HideForGuest servers with full Host, sees others' +// visible servers with redacted Host, never sees others' hidden servers. +func TestFilterServersForViewerOwnerSeesOwnHiddenAndFullHost(t *testing.T) { + servers := makeStreamTestServers() + aliceID := uint64(100) + out := filterServersForViewer(servers, aliceID, false, true) + + assert.Len(t, out, 3, "alice sees her 2 servers + bob's 1 public server") + assert.Nil(t, findStreamServer(out, 4), "alice must not see bob's hidden server") + + aliceHidden := findStreamServer(out, 2) + if assert.NotNil(t, aliceHidden) { + assert.Equal(t, "6.5", aliceHidden.Host.PlatformVersion, "owner sees full Host on her own hidden server") + assert.Equal(t, "agent-v2", aliceHidden.Host.Version) + } + + bobPublic := findStreamServer(out, 3) + if assert.NotNil(t, bobPublic) { + assert.Empty(t, bobPublic.Host.PlatformVersion, "non-owner Host is still redacted even for member viewer") + assert.Empty(t, bobPublic.Host.Version) + } +} + +// Admin: no restrictions — sees every server with full Host, regardless of owner or HideForGuest. +func TestFilterServersForViewerAdminSeesAllWithFullHost(t *testing.T) { + servers := makeStreamTestServers() + out := filterServersForViewer(servers, 999, true, true) + + assert.Len(t, out, 4) + for _, s := range out { + assert.NotEmpty(t, s.Host.PlatformVersion, "admin must see PlatformVersion on every server") + assert.NotEmpty(t, s.Host.Version, "admin must see agent Version on every server") + } +} + +// First-tick frame includes PublicNote, subsequent frames omit it. +// This must hold regardless of viewer. +func TestFilterServersForViewerWithoutPublicNoteFlagOmitsNote(t *testing.T) { + out := filterServersForViewer(makeStreamTestServers(), 0, false, false) + + for _, s := range out { + assert.Empty(t, s.PublicNote, "follow-up frames must not include PublicNote") + } +}