mirror of
https://github.com/matrix-org/sliding-sync.git
synced 2025-03-10 13:37:11 +00:00

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.
412 lines
13 KiB
Go
412 lines
13 KiB
Go
package syncv3_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/matrix-org/complement/b"
|
|
"github.com/matrix-org/complement/client"
|
|
"github.com/matrix-org/sliding-sync/sync3"
|
|
"github.com/matrix-org/sliding-sync/testutils/m"
|
|
)
|
|
|
|
// The purpose of these tests is to ensure that events for one user cannot leak to another user.
|
|
|
|
// Test that events do not leak to users who have left a room.
|
|
// Rationale: When a user leaves a room they should not receive events in that room anymore. However,
|
|
// the v3 server may still be receiving events in that room from other joined members. We need to
|
|
// make sure these events don't find their way to the client.
|
|
// Attack vector:
|
|
// - Alice is using the sync server and is in room !A.
|
|
// - Eve joins the room !A.
|
|
// - Alice kicks Eve.
|
|
// - Alice sends event $X in !A.
|
|
// - Ensure Eve does not see event $X.
|
|
func TestSecurityLiveStreamEventLeftLeak(t *testing.T) {
|
|
alice := registerNewUser(t)
|
|
eve := registerNewUser(t)
|
|
|
|
// Alice and Eve in the room
|
|
roomID := alice.MustCreateRoom(t, map[string]interface{}{
|
|
"preset": "public_chat",
|
|
})
|
|
eve.JoinRoom(t, roomID, nil)
|
|
|
|
// start sync streams for Alice and Eve
|
|
aliceRes := alice.SlidingSync(t, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"a": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 10}, // doesn't matter
|
|
},
|
|
RoomSubscription: sync3.RoomSubscription{
|
|
RequiredState: [][2]string{
|
|
{"m.room.name", ""},
|
|
},
|
|
TimelineLimit: 2,
|
|
},
|
|
}},
|
|
})
|
|
m.MatchResponse(t, aliceRes, m.MatchList("a", m.MatchV3Count(1), m.MatchV3Ops(
|
|
m.MatchV3SyncOp(0, 0, []string{roomID}),
|
|
)))
|
|
eveRes := eve.SlidingSync(t, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"a": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 10}, // doesn't matter
|
|
},
|
|
}},
|
|
})
|
|
m.MatchResponse(t, eveRes, m.MatchList("a", m.MatchV3Count(1), m.MatchV3Ops(
|
|
m.MatchV3SyncOp(0, 0, []string{roomID}),
|
|
)))
|
|
|
|
// kick Eve
|
|
alice.MustDo(t, "POST", []string{"_matrix", "client", "v3", "rooms", roomID, "kick"}, client.WithJSONBody(t, map[string]interface{}{
|
|
"user_id": eve.UserID,
|
|
}))
|
|
|
|
// send message as Alice, note it shouldn't go down Eve's v2 stream
|
|
sensitiveEventID := alice.SendEventSynced(t, roomID, b.Event{
|
|
Type: "m.room.name",
|
|
StateKey: ptr(""),
|
|
Content: map[string]interface{}{
|
|
"name": "I hate Eve",
|
|
},
|
|
})
|
|
|
|
// Ensure Alice sees both events, wait till she gets them
|
|
var timeline []json.RawMessage
|
|
aliceRes = alice.SlidingSyncUntil(t, aliceRes.Pos, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"a": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 10}, // doesn't matter
|
|
},
|
|
RoomSubscription: sync3.RoomSubscription{
|
|
RequiredState: [][2]string{
|
|
{"m.room.name", ""},
|
|
},
|
|
TimelineLimit: 2,
|
|
},
|
|
}},
|
|
}, func(r *sync3.Response) error {
|
|
// keep syncing until we see 2 events in the timeline
|
|
timeline = append(timeline, r.Rooms[roomID].Timeline...)
|
|
if len(timeline) != 2 {
|
|
return fmt.Errorf("waiting for more messages, got %v", len(timeline))
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// check Alice sees both events
|
|
kickEvent := Event{
|
|
Type: "m.room.member",
|
|
StateKey: ptr(eve.UserID),
|
|
Content: map[string]interface{}{
|
|
"membership": "leave",
|
|
},
|
|
Sender: alice.UserID,
|
|
}
|
|
assertEventsEqual(t, []Event{
|
|
kickEvent,
|
|
{
|
|
Type: "m.room.name",
|
|
StateKey: ptr(""),
|
|
Content: map[string]interface{}{
|
|
"name": "I hate Eve",
|
|
},
|
|
Sender: alice.UserID,
|
|
ID: sensitiveEventID,
|
|
},
|
|
}, timeline)
|
|
|
|
// Ensure Eve doesn't see this message in the timeline, name calc or required_state
|
|
eveRes = eve.SlidingSync(t, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"a": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 10}, // doesn't matter
|
|
},
|
|
// Note we are _adding_ this to the list which will kick in logic to return required state / timeline limits
|
|
// so we need to make sure that this returns no data.
|
|
RoomSubscription: sync3.RoomSubscription{
|
|
RequiredState: [][2]string{
|
|
{"m.room.name", ""},
|
|
},
|
|
TimelineLimit: 2,
|
|
},
|
|
}},
|
|
}, WithPos(eveRes.Pos))
|
|
// the room is deleted from eve's point of view and she sees up to and including her kick event
|
|
m.MatchResponse(t, eveRes, m.MatchList("a", m.MatchV3Count(0), m.MatchV3Ops(m.MatchV3DeleteOp(0))), m.MatchRoomSubscription(
|
|
roomID, m.MatchRoomName(""), m.MatchRoomRequiredState(nil), MatchRoomTimelineMostRecent(1, []Event{kickEvent}),
|
|
))
|
|
}
|
|
|
|
// Test that events do not leak via direct room subscriptions.
|
|
// Rationale: Unlike sync v2, in v3 clients can subscribe to any room ID they want as a room_subscription.
|
|
// We need to make sure that the user is allowed to see events in that room before delivering those events.
|
|
// Attack vector:
|
|
// - Alice is using the sync server and is in room !A.
|
|
// - Eve works out the room ID !A (this isn't sensitive information).
|
|
// - Eve starts using the sync server and makes a room_subscription for !A.
|
|
// - Ensure that Eve does not see any events in !A.
|
|
func TestSecurityRoomSubscriptionLeak(t *testing.T) {
|
|
alice := registerNewUser(t)
|
|
eve := registerNewUser(t)
|
|
|
|
// Alice in the room
|
|
alicePrivateRoomID := alice.MustCreateRoom(t, map[string]interface{}{
|
|
"preset": "private_chat",
|
|
})
|
|
|
|
// Eve is in an unrelated room
|
|
eveUnrelatedRoomID := eve.MustCreateRoom(t, map[string]interface{}{
|
|
"preset": "private_chat",
|
|
})
|
|
|
|
// seed the proxy with alice's data
|
|
alice.SlidingSync(t, sync3.Request{})
|
|
|
|
// start sync streams for Eve, with a room subscription to alice's private room
|
|
eveRes := eve.SlidingSync(t, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"a": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 10}, // doesn't matter
|
|
},
|
|
}},
|
|
RoomSubscriptions: map[string]sync3.RoomSubscription{
|
|
alicePrivateRoomID: {
|
|
TimelineLimit: 5,
|
|
RequiredState: [][2]string{
|
|
{"m.room.join_rules", ""},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
// Assert that Eve doesn't see anything
|
|
m.MatchResponse(t, eveRes, m.MatchList("a", m.MatchV3Count(1), m.MatchV3Ops(
|
|
m.MatchV3SyncOp(0, 0, []string{eveUnrelatedRoomID}),
|
|
)), m.MatchRoomSubscriptionsStrict(map[string][]m.RoomMatcher{
|
|
eveUnrelatedRoomID: {},
|
|
}))
|
|
|
|
// Assert that live updates still don't feed through to Eve
|
|
alice.SendEventSynced(t, alicePrivateRoomID, b.Event{
|
|
Type: "m.room.name",
|
|
StateKey: ptr(""),
|
|
Content: map[string]interface{}{
|
|
"name": "I hate Eve",
|
|
},
|
|
})
|
|
eveRes = eve.SlidingSync(t, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"a": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 10}, // doesn't matter
|
|
},
|
|
}},
|
|
}, WithPos(eveRes.Pos))
|
|
|
|
// Assert that Eve doesn't see anything
|
|
m.MatchResponse(t, eveRes, m.MatchList("a", m.MatchV3Count(1)), m.MatchNoV3Ops(), m.MatchRoomSubscriptionsStrict(map[string][]m.RoomMatcher{}))
|
|
}
|
|
|
|
// Test that events do not leak via direct space subscriptions.
|
|
// Rationale: Unlike sync v2, in v3 clients can subscribe to any room ID they want as a space.
|
|
// We need to make sure that the user is allowed to see events in that room before delivering those events.
|
|
// Attack vector:
|
|
// - Alice is using the sync server and is in space !A with room !B.
|
|
// - Eve works out the room ID !A (this isn't sensitive information).
|
|
// - Eve starts using the sync server and makes a request for !A as the space filter.
|
|
// - Ensure that Eve does not see any events in !A or !B.
|
|
func TestSecuritySpaceDataLeak(t *testing.T) {
|
|
alice := registerNewUser(t)
|
|
eve := registerNewUser(t)
|
|
|
|
roomA := alice.MustCreateRoom(t, map[string]interface{}{
|
|
"preset": "public_chat",
|
|
"creation_content": map[string]string{
|
|
"type": "m.space",
|
|
},
|
|
})
|
|
roomB := alice.MustCreateRoom(t, map[string]interface{}{
|
|
"preset": "private_chat",
|
|
})
|
|
alice.SendEventSynced(t, roomA, b.Event{
|
|
Type: "m.space.child",
|
|
StateKey: &roomB,
|
|
Content: map[string]interface{}{
|
|
"via": []string{"example.com"},
|
|
},
|
|
})
|
|
// seed the proxy with alice's data
|
|
alice.SlidingSync(t, sync3.Request{})
|
|
|
|
// ensure eve sees nothing
|
|
res := eve.SlidingSync(t, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"a": {
|
|
Ranges: [][2]int64{{0, 20}},
|
|
Filters: &sync3.RequestFilters{
|
|
Spaces: []string{roomA},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
m.MatchResponse(t, res, m.MatchNoV3Ops(), m.MatchRoomSubscriptionsStrict(nil))
|
|
}
|
|
|
|
// Test that knowledge of a room being in a hidden space does not leak via direct space subscriptions.
|
|
// Rationale: Unlike sync v2, in v3 clients can subscribe to any room ID they want as a space.
|
|
// We need to make sure that if a user is in a room in multiple spaces (only 1 of them the user is joined to)
|
|
// then they cannot see the room if they apply a filter for a parent space they are not joined to.
|
|
// Attack vector:
|
|
// - Alice is using the sync server and is in space !A with room !B.
|
|
// - Eve is using the sync server and is in space !C with room !B as well.
|
|
// - Eve works out the room ID !A (this isn't sensitive information).
|
|
// - Eve starts using the sync server and makes a request for !A as the space filter.
|
|
// - Ensure that Eve does not see anything, even though they are joined to !B and the proxy knows it.
|
|
func TestSecuritySpaceMetadataLeak(t *testing.T) {
|
|
alice := registerNewUser(t)
|
|
eve := registerNewUser(t)
|
|
|
|
roomA := alice.MustCreateRoom(t, map[string]interface{}{
|
|
"preset": "public_chat",
|
|
"creation_content": map[string]string{
|
|
"type": "m.space",
|
|
},
|
|
})
|
|
roomB := alice.MustCreateRoom(t, map[string]interface{}{
|
|
"preset": "public_chat",
|
|
})
|
|
// Alice has a space A -> B
|
|
alice.SendEventSynced(t, roomA, b.Event{
|
|
Type: "m.space.child",
|
|
StateKey: &roomB,
|
|
Content: map[string]interface{}{
|
|
"via": []string{"example.com"},
|
|
},
|
|
})
|
|
// seed the proxy with alice's data
|
|
alice.SlidingSync(t, sync3.Request{})
|
|
|
|
// now Eve also has a space... C -> B
|
|
roomC := eve.MustCreateRoom(t, map[string]interface{}{
|
|
"preset": "public_chat",
|
|
"creation_content": map[string]string{
|
|
"type": "m.space",
|
|
},
|
|
})
|
|
eve.JoinRoom(t, roomB, nil)
|
|
eve.SendEventSynced(t, roomC, b.Event{
|
|
Type: "m.space.child",
|
|
StateKey: &roomB,
|
|
Content: map[string]interface{}{
|
|
"via": []string{"example.com"},
|
|
},
|
|
})
|
|
|
|
// ensure eve sees nothing
|
|
res := eve.SlidingSync(t, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"a": {
|
|
Ranges: [][2]int64{{0, 20}},
|
|
Filters: &sync3.RequestFilters{
|
|
Spaces: []string{roomA},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
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"},
|
|
},
|
|
}}),
|
|
},
|
|
}))
|
|
}
|