diff --git a/packages/federation-sdk/src/repositories/event.repository.ts b/packages/federation-sdk/src/repositories/event.repository.ts index b88f18e17..03b6a090a 100644 --- a/packages/federation-sdk/src/repositories/event.repository.ts +++ b/packages/federation-sdk/src/repositories/event.repository.ts @@ -73,23 +73,15 @@ export class EventRepository { queries = [baseQueries.create, baseQueries.powerLevels]; break; - case 'm.reaction': - case 'm.room.name': - case 'm.room.message': - case 'm.room.encrypted': - case 'm.room.member': - case 'm.room.power_levels': - case 'm.room.topic': - case 'm.room.server_acl': + default: + // for all other events (known and unknown), we need to fetch the create, + // power levels, and membership events for proper authorization queries = [ baseQueries.create, baseQueries.powerLevels, baseQueries.membership, ]; break; - - default: - throw new Error(`Unsupported event type: ${eventType}`); } return this.collection.find({ $or: queries.map((q) => q.query) }); diff --git a/packages/federation-sdk/src/services/state.service.spec.ts b/packages/federation-sdk/src/services/state.service.spec.ts index 0bc5f2504..802b00aae 100644 --- a/packages/federation-sdk/src/services/state.service.spec.ts +++ b/packages/federation-sdk/src/services/state.service.spec.ts @@ -118,7 +118,7 @@ describe('StateService', async () => { } const databaseConfig = { - uri: 'mongodb://localhost:27017', + uri: process.env.MONGO_URI || 'mongodb://localhost:27017', name: 'matrix_test', poolSize: 100, }; @@ -139,8 +139,8 @@ describe('StateService', async () => { beforeEach(async () => { await Promise.all([ - eventCollection.deleteMany(), - stateGraphCollection.deleteMany(), + eventCollection.deleteMany({}), + stateGraphCollection.deleteMany({}), ]); }); @@ -157,6 +157,7 @@ describe('StateService', async () => { const createRoom = async ( joinRule: PduJoinRuleEventContent['join_rule'], userPowers: PduPowerLevelsEventContent['users'] = {}, + eventsPowers: PduPowerLevelsEventContent['events'] = {}, ) => { const username = '@alice:example.com'; const name = 'Test Room'; @@ -212,7 +213,9 @@ describe('StateService', async () => { ...userPowers, }, users_default: 0, - events: {}, + events: { + ...eventsPowers, + }, events_default: 0, state_default: 50, ban: 50, @@ -1275,6 +1278,144 @@ describe('StateService', async () => { expect(bobLeaveEvent.rejected).toBeTrue(); expect(bobLeaveEvent.rejectCode).toBe(RejectCodes.AuthError); }); + it('should reject event if the power level is not high enough', async () => { + const { roomCreateEvent } = await createRoom( + 'public', + {}, + { 'rc.message': 70 }, + ); + const joinEvent = await joinUser( + roomCreateEvent.roomId, + '@bob:example.com', + ); + + const messageEvent = await stateService.buildEvent( + { + // @ts-expect-error - testing unknown event type + type: 'rc.message', + room_id: roomCreateEvent.roomId, + sender: joinEvent.sender, + content: { body: 'hello world', msgtype: 'm.text' }, + ...getDefaultFields(), + }, + roomCreateEvent.getContent().room_version, + ); + + await expect(() => stateService.handlePdu(messageEvent)).toThrow( + RejectCodes.AuthError, + ); + }); + + it('should allow event if the power level is high enough', async () => { + const { roomCreateEvent, creatorMembershipEvent } = await createRoom( + 'public', + {}, + { 'rc.message': 70 }, + ); + + const messageEvent = await stateService.buildEvent( + { + // @ts-expect-error - testing unknown event type + type: 'rc.message', + room_id: roomCreateEvent.roomId, + sender: creatorMembershipEvent.sender, + content: { body: 'hello world', msgtype: 'm.text' }, + ...getDefaultFields(), + }, + roomCreateEvent.getContent().room_version, + ); + + await stateService.handlePdu(messageEvent); + expect(messageEvent.rejected).toBeFalsy(); + }); + + it('12 should save unknown and custom events to database without throwing errors', async () => { + const { roomCreateEvent } = await createRoom('public'); + const roomId = roomCreateEvent.roomId; + const roomVersion = + roomCreateEvent.getContent().room_version; + + // add a user with power to send events (events_default is 0 by default) + const bob = '@bob:example.com' as room.UserID; + await joinUser(roomId, bob); + + // test 1: custom application event as timeline event (io.rocketchat.*) + const customEvent = await stateService.buildEvent( + { + // @ts-expect-error - testing unknown event type + type: 'io.rocketchat.custom', + room_id: roomId, + sender: bob, + // @ts-expect-error - testing unknown event type + content: { custom_field: 'test_value' }, + ...getDefaultFields(), + }, + roomVersion, + ); + + // should not throw when processing custom event + await stateService.handlePdu(customEvent); + expect(customEvent.rejected).toBeFalsy(); + + // verify custom event is saved in database + const savedCustomEvent = await eventRepository.findById( + customEvent.eventId, + ); + expect(savedCustomEvent).toBeDefined(); + // @ts-expect-error - testing unknown event type + expect(savedCustomEvent?.event.type).toBe('io.rocketchat.custom'); + + // test 2: unknown Matrix standard event as timeline event (m.poll.start) + const unknownMatrixEvent = await stateService.buildEvent( + { + // @ts-expect-error - testing unknown event type + type: 'm.poll.start', + room_id: roomId, + sender: bob, + // @ts-expect-error - testing unknown event type + content: { question: 'Test poll?' }, + ...getDefaultFields(), + }, + roomVersion, + ); + + // should not throw when processing unknown Matrix event + await stateService.handlePdu(unknownMatrixEvent); + expect(unknownMatrixEvent.rejected).toBeFalsy(); + + // verify unknown Matrix event is saved in database + const savedUnknownEvent = await eventRepository.findById( + unknownMatrixEvent.eventId, + ); + expect(savedUnknownEvent).toBeDefined(); + // @ts-expect-error - testing unknown event type + expect(savedUnknownEvent?.event.type).toBe('m.poll.start'); + + // test 3: custom event as timeline event (com.example.*) + const anotherCustomEvent = await stateService.buildEvent( + { + // @ts-expect-error - testing unknown event type + type: 'com.example.test', + room_id: roomId, + sender: bob, + // @ts-expect-error - testing unknown event type + content: { data: 'example' }, + ...getDefaultFields(), + }, + roomVersion, + ); + + await stateService.handlePdu(anotherCustomEvent); + expect(anotherCustomEvent.rejected).toBeFalsy(); + + // verify it's saved + const savedExampleEvent = await eventRepository.findById( + anotherCustomEvent.eventId, + ); + expect(savedExampleEvent).toBeDefined(); + // @ts-expect-error - testing unknown event type + expect(savedExampleEvent?.event.type).toBe('com.example.test'); + }); it('01#arriving_late should fix state in case of older event arriving late', async () => { const { roomCreateEvent, powerLevelEvent, roomNameEvent } = diff --git a/packages/room/src/authorizartion-rules/rules.spec.ts b/packages/room/src/authorizartion-rules/rules.spec.ts index 8e746a3b7..832386b1a 100644 --- a/packages/room/src/authorizartion-rules/rules.spec.ts +++ b/packages/room/src/authorizartion-rules/rules.spec.ts @@ -25,11 +25,10 @@ class MockStore implements EventStore { } } -class FakeStateEventCreator { +class FakeEventCreatorBase { protected _event!: Pdu; constructor() { this._event = { - state_key: '', // always a state content: {}, type: '', auth_events: [], @@ -100,7 +99,21 @@ class FakeStateEventCreator { } } -class FakeMessageEventCreator extends FakeStateEventCreator { +class FakeStateEventCreator extends FakeEventCreatorBase { + constructor() { + super(); + this._event.state_key = ''; // state events always have state_key + } +} + +class FakeTimelineEventCreator extends FakeEventCreatorBase { + constructor() { + super(); + // timeline events don't have state_key + } +} + +class FakeMessageEventCreator extends FakeTimelineEventCreator { constructor() { super(); this.withType('m.room.message'); @@ -340,7 +353,7 @@ describe('authorization rules', () => { ).toThrow(); }); - it('06 users below state_default should not be able to send any state', async () => { + it('06 users below events_default should not be able to send unknown events', async () => { const alice = '@alice:example.com'; const bob = '@bob:example.com'; @@ -348,7 +361,8 @@ describe('authorization rules', () => { {}, { events: {}, - state_default: 30, + events_default: 30, + state_default: 50, users: { [alice]: 29, [bob]: 30, @@ -374,18 +388,15 @@ describe('authorization rules', () => { const state = getStateMap([create, join, powerLevel, joinBob, joinAlice]); - const randomStateEvent = new FakeStateEventCreator() + const randomEvent = new FakeTimelineEventCreator() .asTest() .withRoomId(roomId) - .withSender(alice) // should not be able to send any state + .withSender(alice) // should not be able to send (power 29 < events_default 30) .withContent({}) .build(); - expect(() => - checkEventAuthWithState(randomStateEvent, state, store), - ).toThrow(); - - const randomStateEvent2 = new FakeStateEventCreator() + expect(() => checkEventAuthWithState(randomEvent, state, store)).toThrow(); + const randomEvent2 = new FakeTimelineEventCreator() .asTest() .withRoomId(roomId) .withSender(bob) // should be able to send state @@ -393,7 +404,7 @@ describe('authorization rules', () => { .build(); expect(() => - checkEventAuthWithState(randomStateEvent2, state, store), + checkEventAuthWithState(randomEvent2, state, store), ).not.toThrow(); }); @@ -593,7 +604,7 @@ describe('authorization rules', () => { ).not.toThrow(); }); - it('09 should not allow state event sending if power level is too low', async () => { + it('09 should not allow custom event sending if power level is too low', async () => { const alice = '@alice:example.com'; const joinAlice = new FakeStateEventCreator() @@ -620,29 +631,27 @@ describe('authorization rules', () => { return getStateMap([create, join, powerLevel, joinRules, joinAlice]); }; - // alice shoould not be able to send a state event if power is lower than 30 - const state29 = setAlicePower(29); + // alice should not be able to send custom event if power < events_default (50) + const state49 = setAlicePower(49); - const randomStateEvent = new FakeStateEventCreator() + const randomEvent = new FakeTimelineEventCreator() .asTest() .withRoomId(roomId) .withSender(alice) .withContent({}) .build(); - expect(() => - checkEventAuthWithState(randomStateEvent, state29, store), - ).toThrow(); + expect( + checkEventAuthWithState(randomEvent, state49, store), + ).rejects.toThrow(); - // alice should be able to send a state event if power is 30 - const state30 = setAlicePower(30); + // alice should be able to send custom event if power >= events_default (50) + const state50 = setAlicePower(50); - expect(() => - checkEventAuthWithState(randomStateEvent, state30, store), - ).not.toThrow(); + await checkEventAuthWithState(randomEvent, state50, store); // should not be able to send a message if power < 50 - const state49 = setAlicePower(49); + const state49_message = setAlicePower(49); const messageEvent = new FakeMessageEventCreator() .withRoomId(roomId) @@ -650,18 +659,16 @@ describe('authorization rules', () => { .withContent({}) .build(); - expect(() => - checkEventAuthWithState(messageEvent, state49, store), - ).toThrow(); + expect( + checkEventAuthWithState(messageEvent, state49_message, store), + ).rejects.toThrow(); const state51 = setAlicePower(51); - expect(() => - checkEventAuthWithState(messageEvent, state51, store), - ).not.toThrow(); + await checkEventAuthWithState(messageEvent, state51, store); // setting custom power required for test events - const randomStateEvent2 = new FakeStateEventCreator() + const randomEvent2 = new FakeStateEventCreator() .asTest() .withRoomId(roomId) .withSender(alice) @@ -675,10 +682,10 @@ describe('authorization rules', () => { }, { events: { - [randomStateEvent2.type]: 100, + [randomEvent2.type]: 100, }, users: { - [alice]: 51, // alice should not be able to send randomStateEvent2 + [alice]: 51, // alice should not be able to send randomEvent2 (requires 100) }, state_default: 30, @@ -688,9 +695,9 @@ describe('authorization rules', () => { return getStateMap([create, join, powerLevel, joinRules, joinAlice]); })(); - expect(() => - checkEventAuthWithState(randomStateEvent2, stateX, store), - ).toThrow(); + expect( + checkEventAuthWithState(randomEvent2, stateX, store), + ).rejects.toThrow(); // setting custom power required for test events const stateY = (() => { @@ -700,10 +707,10 @@ describe('authorization rules', () => { }, { events: { - [randomStateEvent2.type]: 50, + [randomEvent2.type]: 50, }, users: { - [alice]: 51, // alice should not be able to send randomStateEvent2 + [alice]: 51, // alice should be able to send randomEvent2 (requires 50, has 51) }, }, ); @@ -711,9 +718,104 @@ describe('authorization rules', () => { return getStateMap([create, join, powerLevel, joinRules, joinAlice]); })(); - expect(() => - checkEventAuthWithState(randomStateEvent2, stateY, store), - ).not.toThrow(); + await checkEventAuthWithState(randomEvent2, stateY, store); + }); + + it('09.1 should use events_default for custom and unknown Matrix events', async () => { + const alice = '@alice:example.com'; + + const joinAlice = new FakeStateEventCreator() + .asRoomMember() + .withRoomId(roomId) + .withSender(alice) + .withContent({ membership: 'join' }) + .withStateKey(alice) + .build(); + + const { create, join, powerLevel, joinRules } = getInitialEvents( + { joinRule: 'public' }, + { + events: {}, + users: { + [alice]: 40, + }, + state_default: 50, // state events need 50 + events_default: 30, // unknown/custom events need 30 + }, + ); + + const state = getStateMap([create, join, powerLevel, joinRules, joinAlice]); + + // test custom application event (io.rocketchat.*) + const customRocketChatEvent = new FakeTimelineEventCreator() + // @ts-expect-error - testing unknown event type + .withType('io.rocketchat.custom') + .withRoomId(roomId) + .withSender(alice) // power 40 >= events_default 30, should pass + .withContent({}) + .build(); + + await checkEventAuthWithState(customRocketChatEvent, state, store); + + // test another custom event (com.example.*) + const customExampleEvent = new FakeTimelineEventCreator() + // @ts-expect-error - testing unknown event type + .withType('com.example.event') + .withRoomId(roomId) + .withSender(alice) + .withContent({}) + .build(); + + await checkEventAuthWithState(customExampleEvent, state, store); + + // test unknown Matrix standard event (m.poll.start) + const unknownMatrixEvent = new FakeTimelineEventCreator() + // @ts-expect-error - testing unknown event type + .withType('m.poll.start') + .withRoomId(roomId) + .withSender(alice) + .withContent({}) + .build(); + + await checkEventAuthWithState(unknownMatrixEvent, state, store); + + // verify that power < events_default fails for custom events + const { + create: create2, + join: join2, + powerLevel: powerLevel2, + joinRules: joinRules2, + } = getInitialEvents( + { joinRule: 'public' }, + { + events: {}, + users: { + [alice]: 25, // power 25 < events_default 30, should fail + }, + state_default: 50, + events_default: 30, + }, + ); + + const state2 = getStateMap([ + create2, + join2, + powerLevel2, + joinRules2, + joinAlice, + ]); + + const customEventLowPower = new FakeTimelineEventCreator() + // @ts-expect-error - testing unknown event type + .withType('io.rocketchat.test') + .withRoomId(roomId) + .withSender(alice) + .withContent({}) + .build(); + + expect( + checkEventAuthWithState(customEventLowPower, state2, store), + ).rejects.toThrow(); }); it('10 should resolve power events correctly', async () => { diff --git a/packages/room/src/authorizartion-rules/rules.ts b/packages/room/src/authorizartion-rules/rules.ts index 4805723cd..16d84f3bd 100644 --- a/packages/room/src/authorizartion-rules/rules.ts +++ b/packages/room/src/authorizartion-rules/rules.ts @@ -848,9 +848,8 @@ export async function checkEventAuthWithState( : PowerLevelEvent.fromDefault(); // If the event type’s required power level is greater than the sender’s power level, reject. - const eventRequiredPowerLevel = powerLevelEvent.getRequiredPowerLevelForEvent( - event.type, - ); + const eventRequiredPowerLevel = + powerLevelEvent.getRequiredPowerLevelForEvent(event); const userPowerLevel = powerLevelEvent.getPowerLevelForUser( event.sender, diff --git a/packages/room/src/manager/power-level-event-wrapper.ts b/packages/room/src/manager/power-level-event-wrapper.ts index a0d96c4ba..7caeedeff 100644 --- a/packages/room/src/manager/power-level-event-wrapper.ts +++ b/packages/room/src/manager/power-level-event-wrapper.ts @@ -1,8 +1,4 @@ -import { - type PduPowerLevelsEventContent, - type PduType, - isTimelineEventType, -} from '../types/v3-11'; +import { type PduPowerLevelsEventContent, type PduType } from '../types/v3-11'; import { PersistentEventBase } from './event-wrapper'; import { RoomVersion } from './type'; @@ -91,25 +87,25 @@ class PowerLevelEvent< return createEvent?.sender === userId ? 100 : 0; } - getRequiredPowerLevelForEvent(type: PduType) { + getRequiredPowerLevelForEvent(event: PersistentEventBase) { if (!this._content) { - if (isTimelineEventType(type)) { - return 0; + if (event.isState()) { + return 50; } - return 50; + return 0; } - if (typeof this._content.events?.[type] === 'number') { - return this._content.events[type]; + if (typeof this._content.events?.[event.type] === 'number') { + return this._content.events[event.type]; } - if (isTimelineEventType(type)) { - return this._content.events_default ?? 0; + if (event.isState()) { + return this._content.state_default ?? 50; } - // state events - return this._content.state_default ?? 50; + // unknown or timeline event type - use events_default + return this._content.events_default ?? 0; } // raw transformed values diff --git a/packages/room/src/types/v3-11.ts b/packages/room/src/types/v3-11.ts index d0ae6cdd9..cfee2d9ac 100644 --- a/packages/room/src/types/v3-11.ts +++ b/packages/room/src/types/v3-11.ts @@ -36,7 +36,6 @@ export const PduTypeSchema = z.enum([ 'm.sticker', 'm.beacon_info', 'm.call.invite', - 'm.poll.start', ]); export const EduTypeSchema = z.enum([ @@ -366,6 +365,35 @@ export type PduRoomNameEventContent = z.infer< typeof PduRoomNameEventContentSchema >; +export const PduRoomAvatarEventContentSchema = z.object({ + url: z.string().optional().describe('The URL of the avatar image.'), + info: z + .object({ + height: z.number().optional(), + width: z.number().optional(), + mimetype: z.string().optional(), + size: z.number().optional(), + }) + .optional() + .describe('Metadata about the avatar image.'), + thumbnail_url: z.string().optional().describe('The URL of the thumbnail.'), +}); + +export type PduRoomAvatarEventContent = z.infer< + typeof PduRoomAvatarEventContentSchema +>; + +export const PduRoomPinnedEventsEventContentSchema = z.object({ + pinned: z + .array(eventIdSchema) + .optional() + .describe('An ordered list of event IDs to pin.'), +}); + +export type PduRoomPinnedEventsEventContent = z.infer< + typeof PduRoomPinnedEventsEventContentSchema +>; + // Base timeline content schema const BaseTimelineContentSchema = z.object({ // Optional fields for message edits and relations aka threads @@ -738,6 +766,18 @@ const EventPduTypeRoomRedaction = z.object({ redacts: eventIdSchema.describe('event id'), }); +export const EventPduTypeRoomAvatar = z.object({ + ...PduNoContentEmptyStateKeyStateEventSchema, + type: z.literal('m.room.avatar'), + content: PduRoomAvatarEventContentSchema, +}); + +export const EventPduTypeRoomPinnedEvents = z.object({ + ...PduNoContentEmptyStateKeyStateEventSchema, + type: z.literal('m.room.pinned_events'), + content: PduRoomPinnedEventsEventContentSchema, +}); + export const PduStateEventSchema = z.discriminatedUnion('type', [ EventPduTypeRoomCreate, @@ -764,6 +804,10 @@ export const PduStateEventSchema = z.discriminatedUnion('type', [ EventPduTypeRoomTombstone, EventPduTypeRoomEncryption, + + EventPduTypeRoomAvatar, + + EventPduTypeRoomPinnedEvents, ]); export const PduTimelineSchema = z.discriminatedUnion('type', [ @@ -784,12 +828,3 @@ export const PduSchema = z.discriminatedUnion('type', [ export type Pdu = z.infer & {}; export type PduContent = PduForType['content']; - -export function isTimelineEventType(type: PduType) { - return ( - type === 'm.room.message' || - type === 'm.room.encrypted' || - type === 'm.reaction' || - type === 'm.room.redaction' - ); -}