sliding-sync/tests-e2e/security_test.go
Kegan Dougal 4c6d504022 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.
2024-01-12 12:15:38 +00:00

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"},
},
}}),
},
}))
}