bugfix: ensure metadata about space children doesn't leak to active connections

If Alice and Bob are in the same space, and Bob creates a child in that space,
Alice would incorrectly receive global metadata about that child room if Alice
was live syncing at that time. This leak did not expose confidential information
as Alice could receive all that metadata via the /rooms/{roomId}/hierarchy endpoint
already. However, it would cause clients to put the space child room into the room
list which would be very confusing, as it would have no timeline and no other data.
This commit is contained in:
Kegan Dougal 2024-01-12 12:15:38 +00:00
parent b0d20cefac
commit 4c6d504022
5 changed files with 117 additions and 12 deletions

View File

@ -26,6 +26,10 @@ type TransactionIDFetcher interface {
TransactionIDForEvents(userID, deviceID string, eventIDs []string) (eventIDToTxnID map[string]string) TransactionIDForEvents(userID, deviceID string, eventIDs []string) (eventIDToTxnID map[string]string)
} }
type JoinChecker interface {
IsUserJoined(userID, roomID string) bool
}
// UserRoomData describes a single room from the perspective of particular user. // UserRoomData describes a single room from the perspective of particular user.
// It is primarily used in two places: // It is primarily used in two places:
// - in the caches.UserCache, to hold the latest version of user-specific data; and // - in the caches.UserCache, to hold the latest version of user-specific data; and
@ -194,11 +198,12 @@ type UserCache struct {
store UserCacheStore store UserCacheStore
globalCache *GlobalCache globalCache *GlobalCache
txnIDs TransactionIDFetcher txnIDs TransactionIDFetcher
joinChecker JoinChecker
ignoredUsers map[string]struct{} ignoredUsers map[string]struct{}
ignoredUsersMu *sync.RWMutex ignoredUsersMu *sync.RWMutex
} }
func NewUserCache(userID string, globalCache *GlobalCache, store UserCacheStore, txnIDs TransactionIDFetcher) *UserCache { func NewUserCache(userID string, globalCache *GlobalCache, store UserCacheStore, txnIDs TransactionIDFetcher, joinChecker JoinChecker) *UserCache {
// see SyncLiveHandler.userCache for the initialisation proper, which works by // see SyncLiveHandler.userCache for the initialisation proper, which works by
// firing off a bunch of OnBlahBlah callbacks. // firing off a bunch of OnBlahBlah callbacks.
uc := &UserCache{ uc := &UserCache{
@ -210,6 +215,7 @@ func NewUserCache(userID string, globalCache *GlobalCache, store UserCacheStore,
store: store, store: store,
globalCache: globalCache, globalCache: globalCache,
txnIDs: txnIDs, txnIDs: txnIDs,
joinChecker: joinChecker,
ignoredUsers: make(map[string]struct{}), ignoredUsers: make(map[string]struct{}),
ignoredUsersMu: &sync.RWMutex{}, ignoredUsersMu: &sync.RWMutex{},
} }
@ -572,12 +578,14 @@ func (c *UserCache) OnSpaceUpdate(ctx context.Context, parentRoomID, childRoomID
c.roomToDataMu.Unlock() c.roomToDataMu.Unlock()
// now we need to notify connections for the _child_ // now we need to notify connections for the _child_
roomUpdate := &RoomEventUpdate{ // but only if they are allowed to see the child event (i.e they are joined to the child room)
RoomUpdate: c.newRoomUpdate(ctx, childRoomID), if c.joinChecker.IsUserJoined(c.UserID, childRoomID) {
EventData: eventData, roomUpdate := &RoomEventUpdate{
RoomUpdate: c.newRoomUpdate(ctx, childRoomID),
EventData: eventData,
}
c.emitOnRoomUpdate(ctx, roomUpdate)
} }
c.emitOnRoomUpdate(ctx, roomUpdate)
} }
func (c *UserCache) OnNewEvent(ctx context.Context, eventData *EventData) { func (c *UserCache) OnNewEvent(ctx context.Context, eventData *EventData) {

View File

@ -10,6 +10,12 @@ import (
"github.com/matrix-org/sliding-sync/sync3/caches" "github.com/matrix-org/sliding-sync/sync3/caches"
) )
type joinChecker struct{}
func (c *joinChecker) IsUserJoined(userID, roomID string) bool {
return true
}
type txnIDFetcher struct { type txnIDFetcher struct {
data map[string]string data map[string]string
} }
@ -82,7 +88,7 @@ func TestAnnotateWithTransactionIDs(t *testing.T) {
fetcher := &txnIDFetcher{ fetcher := &txnIDFetcher{
data: tc.eventIDToTxnIDs, data: tc.eventIDToTxnIDs,
} }
uc := caches.NewUserCache(userID, nil, nil, fetcher) uc := caches.NewUserCache(userID, nil, nil, fetcher, &joinChecker{})
got := uc.AnnotateWithTransactionIDs(context.Background(), userID, "DEVICE", convertIDToEventStub(userID, tc.roomIDToEvents)) got := uc.AnnotateWithTransactionIDs(context.Background(), userID, "DEVICE", convertIDToEventStub(userID, tc.roomIDToEvents))
want := convertIDTxnToEventStub(userID, tc.wantRoomIDToEvents) want := convertIDTxnToEventStub(userID, tc.wantRoomIDToEvents)
if !reflect.DeepEqual(got, want) { if !reflect.DeepEqual(got, want) {

View File

@ -18,6 +18,12 @@ import (
"github.com/matrix-org/sliding-sync/testutils" "github.com/matrix-org/sliding-sync/testutils"
) )
type joinChecker struct{}
func (c *joinChecker) IsUserJoined(userID, roomID string) bool {
return true
}
type NopExtensionHandler struct{} type NopExtensionHandler struct{}
func (h *NopExtensionHandler) Handle(ctx context.Context, req extensions.Request, extCtx extensions.Context) (res extensions.Response) { func (h *NopExtensionHandler) Handle(ctx context.Context, req extensions.Request, extCtx extensions.Context) (res extensions.Response) {
@ -106,7 +112,7 @@ func TestConnStateInitial(t *testing.T) {
roomC.RoomID: {NID: 780, Timestamp: 789}, roomC.RoomID: {NID: 780, Timestamp: 789},
}, nil, nil }, nil, nil
} }
userCache := caches.NewUserCache(userID, globalCache, &NopUserCacheStore{}, &NopTransactionFetcher{}) userCache := caches.NewUserCache(userID, globalCache, &NopUserCacheStore{}, &NopTransactionFetcher{}, &joinChecker{})
dispatcher.Register(context.Background(), userCache.UserID, userCache) dispatcher.Register(context.Background(), userCache.UserID, userCache)
dispatcher.Register(context.Background(), sync3.DispatcherAllUsers, globalCache) dispatcher.Register(context.Background(), sync3.DispatcherAllUsers, globalCache)
userCache.LazyLoadTimelinesOverride = func(loadPos int64, roomIDs []string, maxTimelineEvents int) map[string]state.LatestEvents { userCache.LazyLoadTimelinesOverride = func(loadPos int64, roomIDs []string, maxTimelineEvents int) map[string]state.LatestEvents {
@ -279,7 +285,7 @@ func TestConnStateMultipleRanges(t *testing.T) {
} }
return 1, roomMetadata, joinTimings, nil, nil return 1, roomMetadata, joinTimings, nil, nil
} }
userCache := caches.NewUserCache(userID, globalCache, &NopUserCacheStore{}, &NopTransactionFetcher{}) userCache := caches.NewUserCache(userID, globalCache, &NopUserCacheStore{}, &NopTransactionFetcher{}, &joinChecker{})
userCache.LazyLoadTimelinesOverride = mockLazyRoomOverride userCache.LazyLoadTimelinesOverride = mockLazyRoomOverride
dispatcher.Register(context.Background(), userCache.UserID, userCache) dispatcher.Register(context.Background(), userCache.UserID, userCache)
dispatcher.Register(context.Background(), sync3.DispatcherAllUsers, globalCache) dispatcher.Register(context.Background(), sync3.DispatcherAllUsers, globalCache)
@ -458,7 +464,7 @@ func TestBumpToOutsideRange(t *testing.T) {
}, nil, nil }, nil, nil
} }
userCache := caches.NewUserCache(userID, globalCache, &NopUserCacheStore{}, &NopTransactionFetcher{}) userCache := caches.NewUserCache(userID, globalCache, &NopUserCacheStore{}, &NopTransactionFetcher{}, &joinChecker{})
userCache.LazyLoadTimelinesOverride = mockLazyRoomOverride userCache.LazyLoadTimelinesOverride = mockLazyRoomOverride
dispatcher.Register(context.Background(), userCache.UserID, userCache) dispatcher.Register(context.Background(), userCache.UserID, userCache)
dispatcher.Register(context.Background(), sync3.DispatcherAllUsers, globalCache) dispatcher.Register(context.Background(), sync3.DispatcherAllUsers, globalCache)
@ -561,7 +567,7 @@ func TestConnStateRoomSubscriptions(t *testing.T) {
roomD.RoomID: {NID: 4, Timestamp: 4}, roomD.RoomID: {NID: 4, Timestamp: 4},
}, nil, nil }, nil, nil
} }
userCache := caches.NewUserCache(userID, globalCache, &NopUserCacheStore{}, &NopTransactionFetcher{}) userCache := caches.NewUserCache(userID, globalCache, &NopUserCacheStore{}, &NopTransactionFetcher{}, &joinChecker{})
userCache.LazyLoadTimelinesOverride = func(loadPos int64, roomIDs []string, maxTimelineEvents int) map[string]state.LatestEvents { userCache.LazyLoadTimelinesOverride = func(loadPos int64, roomIDs []string, maxTimelineEvents int) map[string]state.LatestEvents {
result := make(map[string]state.LatestEvents) result := make(map[string]state.LatestEvents)
for _, roomID := range roomIDs { for _, roomID := range roomIDs {

View File

@ -528,7 +528,7 @@ func (h *SyncLiveHandler) userCache(userID string) (*caches.UserCache, error) {
if ok { if ok {
return c.(*caches.UserCache), nil return c.(*caches.UserCache), nil
} }
uc := caches.NewUserCache(userID, h.GlobalCache, h.Storage, h) uc := caches.NewUserCache(userID, h.GlobalCache, h.Storage, h, h.Dispatcher)
// select all non-zero highlight or notif counts and set them, as this is less costly than looping every room/user pair // select all non-zero highlight or notif counts and set them, as this is less costly than looping every room/user pair
err := h.Storage.UnreadTable.SelectAllNonZeroCountsForUser(userID, func(roomID string, highlightCount, notificationCount int) { err := h.Storage.UnreadTable.SelectAllNonZeroCountsForUser(userID, func(roomID string, highlightCount, notificationCount int) {
uc.OnUnreadCounts(context.Background(), roomID, &highlightCount, &notificationCount) uc.OnUnreadCounts(context.Background(), roomID, &highlightCount, &notificationCount)

View File

@ -324,3 +324,88 @@ func TestSecuritySpaceMetadataLeak(t *testing.T) {
}) })
m.MatchResponse(t, res, m.MatchNoV3Ops(), m.MatchRoomSubscriptionsStrict(nil)) m.MatchResponse(t, res, m.MatchNoV3Ops(), m.MatchRoomSubscriptionsStrict(nil))
} }
// Test that adding a child room to a space does not leak global room metadata about that
// child room to users in the parent space. This information isn't strictly confidential as
// the /rooms/{roomId}/hierarchy endpoint will include such metadata (room name, avatar, join count, etc)
// because the user is part of the parent space. There isn't an attack vector here, but repro steps:
// - Alice and Bob are in a parent space.
// - Bob has a poller on SS running.
// - Alice is live streaming from SS.
// - Bob creates a child room in that space, and sends both the m.space.parent in the child room AND
// the m.space.child in the parent space.
// - Ensure that no information about the child room comes down Alice's connection.
func TestSecuritySpaceChildMetadataLeakFromParent(t *testing.T) {
alice := registerNewUser(t)
bob := registerNewUser(t)
parentName := "The Parent Room Name"
childName := "The Child Room Name"
// Alice and Bob are in a parent space.
parentSpace := bob.MustCreateRoom(t, map[string]interface{}{
"preset": "public_chat",
"name": parentName,
"creation_content": map[string]string{
"type": "m.space",
},
})
alice.MustJoinRoom(t, parentSpace, []string{"hs1"})
// Bob has a poller on SS running.
bobRes := bob.SlidingSync(t, sync3.Request{})
// Alice is live streaming from SS.
aliceRes := alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
Ranges: [][2]int64{{0, 20}},
},
},
})
m.MatchResponse(t, aliceRes, m.MatchRoomSubscriptionsStrict(map[string][]m.RoomMatcher{
parentSpace: {
m.MatchJoinCount(2),
m.MatchRoomName(parentName),
},
}))
// Bob creates a child room in that space, and sends both the m.space.parent in the child room AND
// the m.space.child in the parent space.
childRoom := bob.MustCreateRoom(t, map[string]interface{}{
"preset": "public_chat",
"name": childName,
"initial_state": []map[string]interface{}{
{
"type": "m.space.parent",
"state_key": parentSpace,
"content": map[string]interface{}{
"canonical": true,
"via": []string{"hs1"},
},
},
},
})
chlidEventID := bob.SendEventSynced(t, parentSpace, b.Event{
Type: "m.space.child",
StateKey: ptr(childRoom),
Content: map[string]interface{}{
"via": []string{"hs1"},
},
})
// wait for SS to process it
bob.SlidingSyncUntilEventID(t, bobRes.Pos, parentSpace, chlidEventID)
// Ensure that no information about the child room comes down Alice's connection.
aliceRes = alice.SlidingSync(t, sync3.Request{}, WithPos(aliceRes.Pos))
m.MatchResponse(t, aliceRes, m.MatchRoomSubscriptionsStrict(map[string][]m.RoomMatcher{
parentSpace: {
MatchRoomTimeline([]Event{{
Type: "m.space.child",
StateKey: ptr(childRoom),
Content: map[string]interface{}{
"via": []interface{}{"hs1"},
},
}}),
},
}))
}