diff --git a/packages/federation-sdk/src/index.ts b/packages/federation-sdk/src/index.ts index 9a439f70b..3ef2a6288 100644 --- a/packages/federation-sdk/src/index.ts +++ b/packages/federation-sdk/src/index.ts @@ -283,6 +283,10 @@ export type HomeserverEventSignatures = { user_id: string; // user who changed the topic topic: string; // new topic of the room }; + 'homeserver.matrix.room.server_acl': { + event_id: EventID; + event: PduForType<'m.room.server_acl'>; + }; 'homeserver.matrix.room.power_levels': { event_id: EventID; event: PduForType<'m.room.power_levels'>; diff --git a/packages/federation-sdk/src/repositories/event.repository.ts b/packages/federation-sdk/src/repositories/event.repository.ts index d13a284f8..b88f18e17 100644 --- a/packages/federation-sdk/src/repositories/event.repository.ts +++ b/packages/federation-sdk/src/repositories/event.repository.ts @@ -80,6 +80,7 @@ export class EventRepository { case 'm.room.member': case 'm.room.power_levels': case 'm.room.topic': + case 'm.room.server_acl': queries = [ baseQueries.create, baseQueries.powerLevels, diff --git a/packages/federation-sdk/src/server-discovery/discovery.ts b/packages/federation-sdk/src/server-discovery/discovery.ts index 02b7d278c..88d0987c6 100644 --- a/packages/federation-sdk/src/server-discovery/discovery.ts +++ b/packages/federation-sdk/src/server-discovery/discovery.ts @@ -1,6 +1,5 @@ import { isIPv4, isIPv6 } from 'node:net'; import memoize from 'memoize'; -import type { Logger } from 'pino'; import { MultiError } from './_multi-error'; import { resolver } from './_resolver'; import { _URL } from './_url'; @@ -85,10 +84,9 @@ export async function resolveHostname( * Server names are resolved to an IP address and port to connect to, and have various conditions affecting which certificates and Host headers to send. */ -// TODO remove logger from here. need to convert this into a service (?) async function getHomeserverFinalAddressInternal( addr: AddressString, - logger?: Logger, + logger?: any, // TODO remove logger from here. need to convert this into a service (?) ): Promise<[IP4or6WithPortAndProtocolString, HostHeaders]> { const url = new _URL(addr); diff --git a/packages/federation-sdk/src/services/event-authorization.service.ts b/packages/federation-sdk/src/services/event-authorization.service.ts index 19d892b07..c8076d407 100644 --- a/packages/federation-sdk/src/services/event-authorization.service.ts +++ b/packages/federation-sdk/src/services/event-authorization.service.ts @@ -16,6 +16,13 @@ import { EventService } from './event.service'; import { ServerService } from './server.service'; import { StateService } from './state.service'; +export class AclDeniedError extends Error { + constructor(serverName: string, roomId: string) { + super(`Sender server ${serverName} denied by room ACL for room ${roomId}`); + this.name = 'AclDeniedError'; + } +} + @singleton() export class EventAuthorizationService { private readonly logger = createLogger('EventAuthorizationService'); @@ -115,10 +122,10 @@ export class EventAuthorizationService { return true; } - private async verifyRequestSignature( + async verifyRequestSignature( + authorizationHeader: string, method: string, uri: string, - authorizationHeader: string, body?: Record, ): Promise { if (!authorizationHeader?.startsWith('X-Matrix')) { @@ -172,104 +179,29 @@ export class EventAuthorizationService { } } - private async canAccessEvent( - eventId: EventID, - serverName: string, - ): Promise { - try { - const event = await this.eventService.getEventById(eventId); - if (!event) { - this.logger.debug(`Event ${eventId} not found`); - return false; - } - - const roomId = event.event.room_id; - const state = await this.stateService.getLatestRoomState(roomId); - - const aclEvent = state.get('m.room.server_acl:'); - const isServerAllowed = await this.checkServerAcl(aclEvent, serverName); - if (!isServerAllowed) { - this.logger.warn( - `Server ${serverName} is denied by room ACL for room ${roomId}`, - ); - return false; - } - - const serversInRoom = await this.stateService.getServersInRoom(roomId); - if (serversInRoom.includes(serverName)) { - this.logger.debug(`Server ${serverName} is in room, allowing access`); - return true; - } - - const historyVisibilityEvent = state.get('m.room.history_visibility:'); - if ( - historyVisibilityEvent?.isHistoryVisibilityEvent() && - historyVisibilityEvent.getContent().history_visibility === - 'world_readable' - ) { - this.logger.debug( - `Event ${eventId} is world_readable, allowing ${serverName}`, - ); - return true; - } + private matchesServerPattern(serverName: string, pattern: string): boolean { + if (serverName === pattern) { + return true; + } - this.logger.debug( - `Server ${serverName} not authorized: not in room and event not world_readable`, - ); - return false; - } catch (err) { - this.logger.error({ msg: 'Error checking event access', err }); + if (pattern.length > 200 || (pattern.match(/[*?]/g) || []).length > 20) { + this.logger.warn(`ACL pattern too complex, rejecting: ${pattern}`); return false; } - } - async canAccessEventFromAuthorizationHeader( - eventId: EventID, - authorizationHeader: string, - method: string, - uri: string, - body?: Record, - ): Promise< - | { authorized: true } - | { - authorized: false; - errorCode: 'M_UNAUTHORIZED' | 'M_FORBIDDEN' | 'M_UNKNOWN'; - } - > { - try { - const signatureResult = await this.verifyRequestSignature( - method, - uri, - authorizationHeader, - body, // keep body due to canonical json validation - ); - if (!signatureResult) { - return { - authorized: false, - errorCode: 'M_UNAUTHORIZED', - }; - } + let regexPattern = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*') + .replace(/\?/g, '.'); - const authorized = await this.canAccessEvent(eventId, signatureResult); - if (!authorized) { - return { - authorized: false, - errorCode: 'M_FORBIDDEN', - }; - } + regexPattern = `^${regexPattern}$`; - return { - authorized: true, - }; + try { + const regex = new RegExp(regexPattern); + return regex.test(serverName); } catch (error) { - this.logger.error( - { error, eventId, authorizationHeader, method, uri, body }, - 'Error checking event access', - ); - return { - authorized: false, - errorCode: 'M_UNKNOWN', - }; + this.logger.warn({ msg: `Invalid ACL pattern: ${pattern}`, error }); + return false; } } @@ -326,131 +258,126 @@ export class EventAuthorizationService { return false; } - private matchesServerPattern(serverName: string, pattern: string): boolean { - if (serverName === pattern) { - return true; - } + async checkAclForInvite(roomId: string, senderServer: string): Promise { + const state = await this.stateService.getLatestRoomState(roomId); - let regexPattern = pattern - .replace(/[.+^${}()|[\]\\]/g, '\\$&') - .replace(/\*/g, '.*') - .replace(/\?/g, '.'); - - regexPattern = `^${regexPattern}$`; + const aclEvent = state.get('m.room.server_acl:'); + if (!aclEvent) { + return; + } - try { - const regex = new RegExp(regexPattern); - return regex.test(serverName); - } catch (error) { - this.logger.warn({ msg: `Invalid ACL pattern: ${pattern}`, error }); - return false; + const isAllowed = await this.checkServerAcl(aclEvent, senderServer); + if (!isAllowed) { + this.logger.warn(`Sender ${senderServer} denied by room ${roomId} ACL`); + throw new AclDeniedError(senderServer, roomId); } } - // TODO duplicated from canAccessEvent. need to refactor into a common method - async canAccessMedia(mediaId: string, serverName: string): Promise { - try { - const rcUpload = await this.uploadRepository.findByMediaId(mediaId); - if (!rcUpload) { - this.logger.debug(`Media ${mediaId} not found in any room`); - return false; - } - - const matrixRoomId = rcUpload.federation.mrid; - - const state = await this.stateService.getLatestRoomState(matrixRoomId); + async serverHasAccessToResource( + roomId: string, + serverName: string, + ): Promise { + const state = await this.stateService.getLatestRoomState(roomId); + if (!state) { + this.logger.debug(`Room ${roomId} not found`); + return false; + } - const aclEvent = state.get('m.room.server_acl:'); - const isServerAllowed = await this.checkServerAcl(aclEvent, serverName); - if (!isServerAllowed) { - this.logger.warn( - `Server ${serverName} is denied by room ACL for media in room ${matrixRoomId}`, - ); - return false; - } + const aclEvent = state.get('m.room.server_acl:'); + const isServerAllowed = await this.checkServerAcl(aclEvent, serverName); + if (!isServerAllowed) { + this.logger.warn( + `Server ${serverName} is denied by room ACL for room ${roomId}`, + ); + return false; + } - const serversInRoom = - await this.stateService.getServersInRoom(matrixRoomId); - if (serversInRoom.includes(serverName)) { - this.logger.debug( - `Server ${serverName} is in room ${matrixRoomId}, allowing media access`, - ); - return true; - } + const serversInRoom = await this.stateService.getServersInRoom(roomId); + if (serversInRoom.includes(serverName)) { + this.logger.debug(`Server ${serverName} is in room, allowing access`); + return true; + } - const historyVisibilityEvent = state.get('m.room.history_visibility:'); - if ( - historyVisibilityEvent?.isHistoryVisibilityEvent() && - historyVisibilityEvent.getContent().history_visibility === - 'world_readable' - ) { - this.logger.debug( - `Room ${matrixRoomId} is world_readable, allowing media access to ${serverName}`, - ); - return true; + for (const [key, event] of state.entries()) { + if (key.startsWith('m.room.member:') && event?.isMembershipEvent()) { + const membership = event.getContent()?.membership; + const stateKey = event.stateKey; + + if (!membership || !stateKey || !stateKey.includes(':')) { + continue; + } + + if (membership === 'invite') { + const invitedUserServer = stateKey.split(':').pop(); + if (invitedUserServer === serverName) { + this.logger.debug( + `Server ${serverName} has pending invites in room, allowing access`, + ); + return true; + } + } } + } + const historyVisibilityEvent = state.get('m.room.history_visibility:'); + if ( + historyVisibilityEvent?.isHistoryVisibilityEvent() && + historyVisibilityEvent.getContent().history_visibility === + 'world_readable' + ) { this.logger.debug( - `Server ${serverName} not authorized for media ${mediaId}: not in room and room not world_readable`, + `Room ${roomId} is world_readable, allowing ${serverName}`, ); + return true; + } + + this.logger.debug( + `Server ${serverName} not authorized: not in room and room not world_readable`, + ); + return false; + } + + async canAccessEvent(eventId: EventID, serverName: string): Promise { + const event = await this.eventService.getEventById(eventId); + if (!event) { + this.logger.debug(`Event ${eventId} not found`); return false; - } catch (error) { - this.logger.error( - { error, mediaId, serverName }, - 'Error checking media access', - ); + } + + return this.serverHasAccessToResource(event.event.room_id, serverName); + } + + async canAccessMedia(mediaId: string, serverName: string): Promise { + const rcUpload = await this.uploadRepository.findByMediaId(mediaId); + if (!rcUpload) { + this.logger.debug(`Media ${mediaId} not found in any room`); return false; } + + return this.serverHasAccessToResource(rcUpload.federation.mrid, serverName); } - // TODO duplicated from canAccessEventFromAuthorizationHeader. need to refactor into a common method - async canAccessMediaFromAuthorizationHeader( - mediaId: string, - authorizationHeader: string, - method: string, - uri: string, - body?: Record, - ): Promise< - | { authorized: true } - | { - authorized: false; - errorCode: 'M_UNAUTHORIZED' | 'M_FORBIDDEN' | 'M_UNKNOWN'; - } - > { - try { - const signatureResult = await this.verifyRequestSignature( - method, - uri, - authorizationHeader, - body, - ); - if (!signatureResult) { - return { - authorized: false, - errorCode: 'M_UNAUTHORIZED', - }; - } + async canAccessRoom(roomId: string, serverName: string): Promise { + return this.serverHasAccessToResource(roomId, serverName); + } - const authorized = await this.canAccessMedia(mediaId, signatureResult); - if (!authorized) { - return { - authorized: false, - errorCode: 'M_FORBIDDEN', - }; - } + async canAccessResource( + entityType: 'event' | 'room' | 'media', + entityId: string, + serverName: string, + ): Promise { + if (entityType === 'event') { + return this.canAccessEvent(entityId as EventID, serverName); + } - return { - authorized: true, - }; - } catch (error) { - this.logger.error( - { error, mediaId, authorizationHeader, method, uri }, - 'Error checking media access', - ); - return { - authorized: false, - errorCode: 'M_UNKNOWN', - }; + if (entityType === 'room') { + return this.canAccessRoom(entityId, serverName); } + + if (entityType === 'media') { + return this.canAccessMedia(entityId, serverName); + } + + return false; } } diff --git a/packages/federation-sdk/src/services/invite.service.ts b/packages/federation-sdk/src/services/invite.service.ts index 56a98f18f..1e0d46e3a 100644 --- a/packages/federation-sdk/src/services/invite.service.ts +++ b/packages/federation-sdk/src/services/invite.service.ts @@ -11,6 +11,10 @@ import { } from '@rocket.chat/federation-room'; import { singleton } from 'tsyringe'; import { ConfigService } from './config.service'; +import { + AclDeniedError, + EventAuthorizationService, +} from './event-authorization.service'; import { EventService } from './event.service'; import { FederationService } from './federation.service'; import { StateService, UnknownRoomError } from './state.service'; @@ -31,6 +35,7 @@ export class InviteService { private readonly federationService: FederationService, private readonly stateService: StateService, private readonly configService: ConfigService, + private readonly eventAuthorizationService: EventAuthorizationService, ) {} /** @@ -142,6 +147,7 @@ export class InviteService { roomId: RoomID, eventId: EventID, roomVersion: RoomVersion, + authenticatedServer: string, ) { // SPEC: when a user invites another user on a different homeserver, a request to that homeserver to have the event signed and verified must be made @@ -162,8 +168,12 @@ export class InviteService { await this.stateService.signEvent(inviteEvent); + // we are the host of the server if (residentServer === this.configService.serverName) { - // we are the host of the server + await this.eventAuthorizationService.checkAclForInvite( + roomId, + authenticatedServer, + ); // attempt to persist the invite event as we already have the state @@ -171,9 +181,6 @@ export class InviteService { // we do not send transaction here // the asking server will handle the transactions - - // return the signed invite event - return inviteEvent; } // we are not the host of the server diff --git a/packages/federation-sdk/src/services/staging-area.service.ts b/packages/federation-sdk/src/services/staging-area.service.ts index b076e3bd3..8e5bb7b50 100644 --- a/packages/federation-sdk/src/services/staging-area.service.ts +++ b/packages/federation-sdk/src/services/staging-area.service.ts @@ -331,6 +331,13 @@ export class StagingAreaService { }); break; } + case event.event.type === 'm.room.server_acl': { + this.eventEmitterService.emit('homeserver.matrix.room.server_acl', { + event_id: eventId, + event: event.event, + }); + break; + } case event.event.type === 'm.room.power_levels': { this.eventEmitterService.emit('homeserver.matrix.room.power_levels', { event_id: eventId, diff --git a/packages/homeserver/src/controllers/federation/invite.controller.ts b/packages/homeserver/src/controllers/federation/invite.controller.ts index 7fca12317..0f81c0ca2 100644 --- a/packages/homeserver/src/controllers/federation/invite.controller.ts +++ b/packages/homeserver/src/controllers/federation/invite.controller.ts @@ -1,19 +1,30 @@ import { EventID, RoomID } from '@rocket.chat/federation-room'; -import { InviteService } from '@rocket.chat/federation-sdk'; +import { + EventAuthorizationService, + InviteService, +} from '@rocket.chat/federation-sdk'; +import { isAuthenticatedMiddleware } from '@rocket.chat/homeserver/middlewares/isAuthenticated'; import { Elysia, t } from 'elysia'; import { container } from 'tsyringe'; import { ProcessInviteParamsDto, RoomVersionDto } from '../../dtos'; export const invitePlugin = (app: Elysia) => { const inviteService = container.resolve(InviteService); - return app.put( + const eventAuthService = container.resolve(EventAuthorizationService); + + return app.use(isAuthenticatedMiddleware(eventAuthService)).put( '/_matrix/federation/v2/invite/:roomId/:eventId', - async ({ body, params: { roomId, eventId } }) => { + async ({ body, params: { roomId, eventId }, authenticatedServer }) => { + if (!authenticatedServer) { + throw new Error('Missing authenticated server from request'); + } + return inviteService.processInvite( body.event, roomId as RoomID, eventId as EventID, body.room_version, + authenticatedServer, ); }, { diff --git a/packages/homeserver/src/controllers/federation/media.controller.ts b/packages/homeserver/src/controllers/federation/media.controller.ts index e951aabd7..74259edf6 100644 --- a/packages/homeserver/src/controllers/federation/media.controller.ts +++ b/packages/homeserver/src/controllers/federation/media.controller.ts @@ -1,4 +1,8 @@ +import { EventAuthorizationService } from '@rocket.chat/federation-sdk'; +import { canAccessResourceMiddleware } from '@rocket.chat/homeserver/middlewares/canAccessResource'; +import { isAuthenticatedMiddleware } from '@rocket.chat/homeserver/middlewares/isAuthenticated'; import { Elysia, t } from 'elysia'; +import { container } from 'tsyringe'; const ErrorResponseSchema = t.Object({ errcode: t.Literal('M_UNRECOGNIZED'), @@ -11,8 +15,32 @@ const ErrorResponseSchema = t.Object({ * All the medias are being handled by the Rocket.Chat instances. */ export const mediaPlugin = (app: Elysia) => { + const eventAuthService = container.resolve(EventAuthorizationService); + return app.group('/_matrix', (app) => app + .use(isAuthenticatedMiddleware(eventAuthService)) + .get( + '/media/v3/config', + async ({ set }) => { + set.status = 404; + return { + errcode: 'M_UNRECOGNIZED', + error: 'This endpoint is not implemented on homeserver side', + }; + }, + { + response: { + 404: ErrorResponseSchema, + }, + detail: { + tags: ['Media'], + summary: 'Get media configuration', + description: 'Get the media configuration for the homeserver', + }, + }, + ) + .use(canAccessResourceMiddleware(eventAuthService, 'media')) .get( '/federation/v1/media/download/:mediaId', async ({ set }) => { @@ -118,27 +146,6 @@ export const mediaPlugin = (app: Elysia) => { description: 'Get a thumbnail for a media file', }, }, - ) - - .get( - '/media/v3/config', - async ({ set }) => { - set.status = 404; - return { - errcode: 'M_UNRECOGNIZED', - error: 'This endpoint is not implemented on homeserver side', - }; - }, - { - response: { - 404: ErrorResponseSchema, - }, - detail: { - tags: ['Media'], - summary: 'Get media configuration', - description: 'Get the media configuration for the homeserver', - }, - }, ), ); }; diff --git a/packages/homeserver/src/controllers/federation/profiles.controller.ts b/packages/homeserver/src/controllers/federation/profiles.controller.ts index b971dd243..37b550be4 100644 --- a/packages/homeserver/src/controllers/federation/profiles.controller.ts +++ b/packages/homeserver/src/controllers/federation/profiles.controller.ts @@ -1,10 +1,10 @@ +import { EventID, RoomID, UserID } from '@rocket.chat/federation-room'; import { - EventID, - RoomID, - type RoomVersion, - UserID, -} from '@rocket.chat/federation-room'; -import { ProfilesService } from '@rocket.chat/federation-sdk'; + EventAuthorizationService, + ProfilesService, +} from '@rocket.chat/federation-sdk'; +import { canAccessResourceMiddleware } from '@rocket.chat/homeserver/middlewares/canAccessResource'; +import { isAuthenticatedMiddleware } from '@rocket.chat/homeserver/middlewares/isAuthenticated'; import { Elysia, t } from 'elysia'; import { container } from 'tsyringe'; import { @@ -16,12 +16,6 @@ import { GetMissingEventsBodyDto, GetMissingEventsParamsDto, GetMissingEventsResponseDto, - GetStateIdsParamsDto, - GetStateIdsQueryDto, - GetStateIdsResponseDto, - GetStateParamsDto, - GetStateQueryDto, - GetStateResponseDto, MakeJoinParamsDto, MakeJoinQueryDto, MakeJoinResponseDto, @@ -33,54 +27,80 @@ import { export const profilesPlugin = (app: Elysia) => { const profilesService = container.resolve(ProfilesService); + const eventAuthService = container.resolve(EventAuthorizationService); return app - .get( - '/_matrix/federation/v1/query/profile', - ({ query: { user_id } }) => - profilesService.queryProfile(user_id as UserID), - { - query: QueryProfileQueryDto, - response: { - 200: QueryProfileResponseDto, - }, - detail: { - tags: ['Federation'], - summary: 'Query profile', - description: "Query a user's profile", - }, - }, - ) - .post( - '/_matrix/federation/v1/user/keys/query', - async ({ body }) => profilesService.queryKeys(body.device_keys), - { - body: QueryKeysBodyDto, - response: { - 200: QueryKeysResponseDto, - }, - detail: { - tags: ['Federation'], - summary: 'Query keys', - description: "Query a user's device keys", - }, - }, - ) - .get( - '/_matrix/federation/v1/user/devices/:userId', - ({ params }) => profilesService.getDevices(params.userId as UserID), - { - params: GetDevicesParamsDto, - response: { - 200: GetDevicesResponseDto, - }, - detail: { - tags: ['Federation'], - summary: 'Get devices', - description: "Get a user's devices", - }, - }, + .group('/_matrix', (app) => + app + .use(isAuthenticatedMiddleware(eventAuthService)) + .get( + '/federation/v1/query/profile', + ({ query: { user_id } }) => + profilesService.queryProfile(user_id as UserID), + { + query: QueryProfileQueryDto, + response: { + 200: QueryProfileResponseDto, + }, + detail: { + tags: ['Federation'], + summary: 'Query profile', + description: "Query a user's profile", + }, + }, + ) + .post( + '/federation/v1/user/keys/query', + async ({ set }) => { + set.status = 501; + return { + errcode: 'M_UNRECOGNIZED', + error: 'E2EE is not implemented yet', + }; + }, + { + body: QueryKeysBodyDto, + response: { + 200: QueryKeysResponseDto, + 501: t.Object({ + errcode: t.String(), + error: t.String(), + }), + }, + detail: { + tags: ['Federation'], + summary: 'Query keys', + description: "Query a user's device keys (E2EE not implemented)", + }, + }, + ) + .get( + '/federation/v1/user/devices/:userId', + async ({ set }) => { + set.status = 501; + return { + errcode: 'M_UNRECOGNIZED', + error: 'E2EE is not implemented yet', + }; + }, + { + params: GetDevicesParamsDto, + response: { + 200: GetDevicesResponseDto, + 501: t.Object({ + errcode: t.String(), + error: t.String(), + }), + }, + detail: { + tags: ['Federation'], + summary: 'Get devices', + description: "Get a user's devices (E2EE not implemented)", + }, + }, + ), ) + .use(canAccessResourceMiddleware(eventAuthService, 'room')) .get( '/_matrix/federation/v1/make_join/:roomId/:userId', async ({ params, query: _query }) => { diff --git a/packages/homeserver/src/controllers/federation/rooms.controller.ts b/packages/homeserver/src/controllers/federation/rooms.controller.ts index e991bc530..90080988e 100644 --- a/packages/homeserver/src/controllers/federation/rooms.controller.ts +++ b/packages/homeserver/src/controllers/federation/rooms.controller.ts @@ -1,117 +1,122 @@ -import { StateService } from '@rocket.chat/federation-sdk'; +import { + EventAuthorizationService, + StateService, +} from '@rocket.chat/federation-sdk'; +import { isAuthenticatedMiddleware } from '@rocket.chat/homeserver/middlewares/isAuthenticated'; import { Elysia, t } from 'elysia'; import { container } from 'tsyringe'; export const roomPlugin = (app: Elysia) => { const stateService = container.resolve(StateService); + const eventAuthService = container.resolve(EventAuthorizationService); - app.get( - '/_matrix/federation/v1/publicRooms', - async ({ query }) => { - const defaultObj = { - join_rule: 'public', - guest_can_join: false, // trying to reduce requried endpoint hits - world_readable: false, // ^^^ - avatar_url: '', // ?? don't have any yet - }; + return app + .use(isAuthenticatedMiddleware(eventAuthService)) + .get( + '/_matrix/federation/v1/publicRooms', + async ({ query }) => { + const defaultObj = { + join_rule: 'public', + guest_can_join: false, // trying to reduce requried endpoint hits + world_readable: false, // ^^^ + avatar_url: '', // ?? don't have any yet + }; - const { limit: _limit } = query; + const { limit: _limit } = query; - const publicRooms = await stateService.getAllPublicRoomIdsAndNames(); + const publicRooms = await stateService.getAllPublicRoomIdsAndNames(); - return { - chunk: publicRooms.map((room: any) => ({ - ...defaultObj, - ...room, - })), - }; - }, - { - query: t.Object({ - include_all_networks: t.Boolean(), // we ignore this - limit: t.Number(), - }), - response: t.Object({ - chunk: t.Array( - t.Object({ - avatar_url: t.String(), - canonical_alias: t.Optional(t.String()), - guest_can_join: t.Boolean(), - join_rule: t.String(), - name: t.String(), - num_joined_members: t.Optional(t.Number()), - room_id: t.String(), - room_type: t.Optional(t.String()), - topic: t.Optional(t.String()), - world_readable: t.Boolean(), - }), - ), - }), - }, - ); - - app.post( - '/_matrix/federation/v1/publicRooms', - async ({ body }) => { - const defaultObj = { - join_rule: 'public', - guest_can_join: false, // trying to reduce requried endpoint hits - world_readable: false, // ^^^ - avatar_url: '', // ?? don't have any yet - }; + return { + chunk: publicRooms.map((room) => ({ + ...defaultObj, + ...room, + })), + }; + }, + { + query: t.Object({ + include_all_networks: t.Boolean(), // we ignore this + limit: t.Number(), + }), + response: t.Object({ + chunk: t.Array( + t.Object({ + avatar_url: t.String(), + canonical_alias: t.Optional(t.String()), + guest_can_join: t.Boolean(), + join_rule: t.String(), + name: t.String(), + num_joined_members: t.Optional(t.Number()), + room_id: t.String(), + room_type: t.Optional(t.String()), + topic: t.Optional(t.String()), + world_readable: t.Boolean(), + }), + ), + }), + }, + ) + .post( + '/_matrix/federation/v1/publicRooms', + async ({ body }) => { + const defaultObj = { + join_rule: 'public', + guest_can_join: false, // trying to reduce requried endpoint hits + world_readable: false, // ^^^ + avatar_url: '', // ?? don't have any yet + }; - const { filter } = body; + const { filter } = body; - const publicRooms = await stateService.getAllPublicRoomIdsAndNames(); + const publicRooms = await stateService.getAllPublicRoomIdsAndNames(); - return { - chunk: publicRooms - .filter((r) => { - if (filter.generic_search_term) { - return r.name - .toLowerCase() - .includes(filter.generic_search_term.toLowerCase()); - } + return { + chunk: publicRooms + .filter((r) => { + if (filter.generic_search_term) { + return r.name + .toLowerCase() + .includes(filter.generic_search_term.toLowerCase()); + } - if (filter.room_types) { - // TODO: - } + if (filter.room_types) { + // TODO: + } - return true; - }) - .map((room: any) => ({ - ...defaultObj, - ...room, - })), - }; - }, - { - // {"filter":{"generic_search_term":"","room_types":[null]},"include_all_networks":"false","limit":50} - body: t.Object({ - include_all_networks: t.Optional(t.Boolean()), // we ignore this - limit: t.Optional(t.Number()), - filter: t.Object({ - generic_search_term: t.Optional(t.String()), - room_types: t.Optional(t.Array(t.Union([t.String(), t.Null()]))), - }), - }), - response: t.Object({ - chunk: t.Array( - t.Object({ - avatar_url: t.String(), - canonical_alias: t.Optional(t.String()), - guest_can_join: t.Boolean(), - join_rule: t.String(), - name: t.String(), - num_joined_members: t.Optional(t.Number()), - room_id: t.String(), - room_type: t.Optional(t.String()), - topic: t.Optional(t.String()), - world_readable: t.Boolean(), + return true; + }) + .map((room) => ({ + ...defaultObj, + ...room, + })), + }; + }, + { + // {"filter":{"generic_search_term":"","room_types":[null]},"include_all_networks":"false","limit":50} + body: t.Object({ + include_all_networks: t.Optional(t.Boolean()), // we ignore this + limit: t.Optional(t.Number()), + filter: t.Object({ + generic_search_term: t.Optional(t.String()), + room_types: t.Optional(t.Array(t.Union([t.String(), t.Null()]))), }), - ), - }), - }, - ); - return app; + }), + response: t.Object({ + chunk: t.Array( + t.Object({ + avatar_url: t.String(), + canonical_alias: t.Optional(t.String()), + guest_can_join: t.Boolean(), + join_rule: t.String(), + name: t.String(), + num_joined_members: t.Optional(t.Number()), + room_id: t.String(), + room_type: t.Optional(t.String()), + topic: t.Optional(t.String()), + world_readable: t.Boolean(), + }), + ), + }), + }, + ); }; diff --git a/packages/homeserver/src/controllers/federation/send-join.controller.ts b/packages/homeserver/src/controllers/federation/send-join.controller.ts index 45562656a..f02dca526 100644 --- a/packages/homeserver/src/controllers/federation/send-join.controller.ts +++ b/packages/homeserver/src/controllers/federation/send-join.controller.ts @@ -1,5 +1,9 @@ import type { EventID, RoomID } from '@rocket.chat/federation-room'; -import { SendJoinService } from '@rocket.chat/federation-sdk'; +import { + EventAuthorizationService, + SendJoinService, +} from '@rocket.chat/federation-sdk'; +import { canAccessResourceMiddleware } from '@rocket.chat/homeserver/middlewares/canAccessResource'; import { Elysia, t } from 'elysia'; import { container } from 'tsyringe'; import { @@ -10,8 +14,9 @@ import { export const sendJoinPlugin = (app: Elysia) => { const sendJoinService = container.resolve(SendJoinService); + const eventAuthService = container.resolve(EventAuthorizationService); - return app.put( + return app.use(canAccessResourceMiddleware(eventAuthService, 'room')).put( '/_matrix/federation/v2/send_join/:roomId/:eventId', async ({ params, diff --git a/packages/homeserver/src/controllers/federation/state.controller.ts b/packages/homeserver/src/controllers/federation/state.controller.ts index 42680612d..33ece4bc3 100644 --- a/packages/homeserver/src/controllers/federation/state.controller.ts +++ b/packages/homeserver/src/controllers/federation/state.controller.ts @@ -1,5 +1,9 @@ import { EventID, RoomID } from '@rocket.chat/federation-room'; -import { EventService } from '@rocket.chat/federation-sdk'; +import { + EventAuthorizationService, + EventService, +} from '@rocket.chat/federation-sdk'; +import { canAccessResourceMiddleware } from '@rocket.chat/homeserver/middlewares/canAccessResource'; import { Elysia } from 'elysia'; import { container } from 'tsyringe'; import { @@ -14,8 +18,10 @@ import { export const statePlugin = (app: Elysia) => { const eventService = container.resolve(EventService); + const eventAuthService = container.resolve(EventAuthorizationService); return app + .use(canAccessResourceMiddleware(eventAuthService, 'room')) .get( '/_matrix/federation/v1/state_ids/:roomId', ({ params, query }) => diff --git a/packages/homeserver/src/controllers/federation/transactions.controller.ts b/packages/homeserver/src/controllers/federation/transactions.controller.ts index e258dd5f8..3dbc10cf4 100644 --- a/packages/homeserver/src/controllers/federation/transactions.controller.ts +++ b/packages/homeserver/src/controllers/federation/transactions.controller.ts @@ -4,6 +4,8 @@ import { EventAuthorizationService, EventService, } from '@rocket.chat/federation-sdk'; +import { canAccessResourceMiddleware } from '@rocket.chat/homeserver/middlewares/canAccessResource'; +import { isAuthenticatedMiddleware } from '@rocket.chat/homeserver/middlewares/isAuthenticated'; import { Elysia } from 'elysia'; import { container } from 'tsyringe'; import { @@ -18,7 +20,6 @@ import { SendTransactionBodyDto, SendTransactionResponseDto, } from '../../dtos'; -import { canAccessEvent } from '../../middlewares/acl.middleware'; export const transactionsPlugin = (app: Elysia) => { const eventService = container.resolve(EventService); @@ -39,6 +40,7 @@ export const transactionsPlugin = (app: Elysia) => { }; }, { + use: isAuthenticatedMiddleware(eventAuthService), body: SendTransactionBodyDto, response: { 200: SendTransactionResponseDto, @@ -51,6 +53,7 @@ export const transactionsPlugin = (app: Elysia) => { }, }, ) + .get( '/_matrix/federation/v1/event/:eventId', async ({ params, set }) => { @@ -72,7 +75,7 @@ export const transactionsPlugin = (app: Elysia) => { }; }, { - use: canAccessEvent(eventAuthService), + use: canAccessResourceMiddleware(eventAuthService, 'event'), params: GetEventParamsDto, response: { 200: GetEventResponseDto, @@ -88,6 +91,7 @@ export const transactionsPlugin = (app: Elysia) => { }, }, ) + .get( '/_matrix/federation/v1/backfill/:roomId', async ({ params, query, set }) => { @@ -120,6 +124,7 @@ export const transactionsPlugin = (app: Elysia) => { } }, { + use: canAccessResourceMiddleware(eventAuthService, 'room'), params: BackfillParamsDto, query: BackfillQueryDto, response: { diff --git a/packages/homeserver/src/middlewares/acl.middleware.ts b/packages/homeserver/src/middlewares/acl.middleware.ts deleted file mode 100644 index 3b6604312..000000000 --- a/packages/homeserver/src/middlewares/acl.middleware.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { EventID } from '@rocket.chat/federation-room'; -import type { EventAuthorizationService } from '@rocket.chat/federation-sdk'; -import { errCodes } from '@rocket.chat/federation-sdk'; -import Elysia from 'elysia'; - -export const canAccessEvent = (federationAuth: EventAuthorizationService) => { - return new Elysia({ - name: 'homeserver/canAccessEvent', - }).onBeforeHandle<{ params: { eventId: string } }>(async (req) => { - const { params, headers, request, set } = req; - const { eventId } = params; - const authorizationHeader = headers.authorization || ''; - const method = request.method; - const uri = new URL(request.url).pathname; - - const result = await federationAuth.canAccessEventFromAuthorizationHeader( - eventId as EventID, - authorizationHeader, - method, - uri, - ); - - if (!result.authorized) { - set.status = errCodes[result.errorCode].status; - return { - errcode: errCodes[result.errorCode].errcode, - error: errCodes[result.errorCode].error, - }; - } - }); -}; diff --git a/packages/homeserver/src/middlewares/canAccessResource.ts b/packages/homeserver/src/middlewares/canAccessResource.ts new file mode 100644 index 000000000..38485e1e5 --- /dev/null +++ b/packages/homeserver/src/middlewares/canAccessResource.ts @@ -0,0 +1,70 @@ +import type { EventAuthorizationService } from '@rocket.chat/federation-sdk'; +import { errCodes } from '@rocket.chat/federation-sdk'; +import Elysia from 'elysia'; +import { isAuthenticatedMiddleware } from './isAuthenticated'; + +function extractEntityId( + params: { roomId?: string; mediaId?: string; eventId?: string }, + entityType: 'event' | 'media' | 'room', +): string | null { + if (entityType === 'room') { + return params.roomId ?? null; + } + + if (entityType === 'media') { + return params.mediaId ?? null; + } + + if (entityType === 'event') { + return params.eventId ?? null; + } + + return null; +} + +export const canAccessResourceMiddleware = ( + federationAuth: EventAuthorizationService, + entityType: 'event' | 'media' | 'room', +) => { + return new Elysia({ name: 'homeserver/canAccessResource' }) + .use(isAuthenticatedMiddleware(federationAuth)) + .onBeforeHandle(async ({ params, authenticatedServer, set }) => { + try { + if (!authenticatedServer) { + set.status = errCodes.M_UNAUTHORIZED.status; + return { + errcode: errCodes.M_UNAUTHORIZED.errcode, + error: 'Authentication required', + }; + } + + const resourceId = extractEntityId(params, entityType); + if (!resourceId) { + set.status = 400; + return { + errcode: 'M_INVALID_PARAM', + error: `Missing required ${entityType} identifier`, + }; + } + + const resourceAccess = await federationAuth.canAccessResource( + entityType, + resourceId, + authenticatedServer, + ); + if (!resourceAccess) { + set.status = errCodes.M_FORBIDDEN.status; + return { + errcode: errCodes.M_FORBIDDEN.errcode, + error: 'Access denied to resource', + }; + } + } catch (_err) { + set.status = errCodes.M_UNKNOWN.status; + return { + errcode: errCodes.M_UNKNOWN.errcode, + error: 'Internal server error', + }; + } + }); +}; diff --git a/packages/homeserver/src/middlewares/isAuthenticated.ts b/packages/homeserver/src/middlewares/isAuthenticated.ts new file mode 100644 index 000000000..9588c0174 --- /dev/null +++ b/packages/homeserver/src/middlewares/isAuthenticated.ts @@ -0,0 +1,71 @@ +import type { EventAuthorizationService } from '@rocket.chat/federation-sdk'; +import Elysia from 'elysia'; + +export const isAuthenticatedMiddleware = ( + federationAuth: EventAuthorizationService, +) => { + return new Elysia({ + name: 'homeserver/isAuthenticated', + }) + .derive({ as: 'global' }, async ({ headers, request, set }) => { + const authorizationHeader = headers.authorization; + const method = request.method; + const url = new URL(request.url); + const uri = url.pathname + url.search; + + if (!authorizationHeader) { + set.status = 401; + return { + authenticatedServer: undefined, + }; + } + + try { + let body: Record | undefined; + if (request.body) { + try { + const clone = request.clone(); + const text = await clone.text(); + body = text ? JSON.parse(text) : undefined; + } catch { + body = undefined; + } + } + + const isValid = await federationAuth.verifyRequestSignature( + authorizationHeader, + method, + uri, + body, + ); + + if (!isValid) { + set.status = 401; + return { + authenticatedServer: undefined, + }; + } + + return { + authenticatedServer: isValid, + }; + } catch (error) { + console.error('Authentication error:', error); + set.status = 500; + return { + authenticatedServer: undefined, + }; + } + }) + .onBeforeHandle(({ authenticatedServer, set }) => { + if (!authenticatedServer) { + return { + errcode: set.status === 500 ? 'M_UNKNOWN' : 'M_UNAUTHORIZED', + error: + set.status === 500 + ? 'Internal server error' + : 'Authentication required', + }; + } + }); +};