From 1b3fcfe31fe2fd2b65441233159c01bb2b0a2cf5 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 25 Sep 2025 13:40:54 -0300 Subject: [PATCH 01/20] add canAccessResource and isAuthenticated middlewares --- .../services/event-authorization.service.ts | 283 +++++------------- .../federation/invite.controller.ts | 60 ++-- .../federation/media.controller.ts | 244 +++++++-------- .../federation/profiles.controller.ts | 21 +- .../federation/transactions.controller.ts | 5 +- .../src/middlewares/acl.middleware.ts | 31 -- .../src/middlewares/canAccessResource.ts | 66 ++++ packages/homeserver/src/middlewares/index.ts | 2 + .../src/middlewares/isAuthenticated.ts | 68 +++++ 9 files changed, 393 insertions(+), 387 deletions(-) delete mode 100644 packages/homeserver/src/middlewares/acl.middleware.ts create mode 100644 packages/homeserver/src/middlewares/canAccessResource.ts create mode 100644 packages/homeserver/src/middlewares/index.ts create mode 100644 packages/homeserver/src/middlewares/isAuthenticated.ts diff --git a/packages/federation-sdk/src/services/event-authorization.service.ts b/packages/federation-sdk/src/services/event-authorization.service.ts index 19d892b07..6a3cf96d1 100644 --- a/packages/federation-sdk/src/services/event-authorization.service.ts +++ b/packages/federation-sdk/src/services/event-authorization.service.ts @@ -115,10 +115,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 +172,24 @@ 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; - } - - 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 }); - return false; + private matchesServerPattern(serverName: string, pattern: string): boolean { + if (serverName === pattern) { + return true; } - } - 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 +246,86 @@ export class EventAuthorizationService { return false; } - private matchesServerPattern(serverName: string, pattern: string): boolean { - if (serverName === pattern) { + async serverHasAccessToResource( + roomId: string, + serverName: string, + ): Promise { + const state = await this.stateService.getFullRoomState(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; } - let regexPattern = pattern - .replace(/[.+^${}()|[\]\\]/g, '\\$&') - .replace(/\*/g, '.*') - .replace(/\?/g, '.'); + const historyVisibilityEvent = state.get('m.room.history_visibility:'); + if ( + historyVisibilityEvent?.isHistoryVisibilityEvent() && + historyVisibilityEvent.getContent().history_visibility === + 'world_readable' + ) { + this.logger.debug( + `Room ${roomId} is world_readable, allowing ${serverName}`, + ); + return true; + } - regexPattern = `^${regexPattern}$`; + this.logger.debug( + `Server ${serverName} not authorized: not in room and room not world_readable`, + ); + return false; + } - try { - const regex = new RegExp(regexPattern); - return regex.test(serverName); - } catch (error) { - this.logger.warn({ msg: `Invalid ACL pattern: ${pattern}`, error }); + 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; } + + return this.serverHasAccessToResource(event.event.room_id, serverName); } - // 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); - - 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 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 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; - } - - this.logger.debug( - `Server ${serverName} not authorized for media ${mediaId}: not in room and room not world_readable`, - ); - return false; - } catch (error) { - this.logger.error( - { error, mediaId, serverName }, - 'Error checking media access', - ); + 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/homeserver/src/controllers/federation/invite.controller.ts b/packages/homeserver/src/controllers/federation/invite.controller.ts index 7fca12317..e43fb73de 100644 --- a/packages/homeserver/src/controllers/federation/invite.controller.ts +++ b/packages/homeserver/src/controllers/federation/invite.controller.ts @@ -1,33 +1,45 @@ import { EventID, RoomID } from '@rocket.chat/federation-room'; -import { InviteService } from '@rocket.chat/federation-sdk'; +import { + EventAuthorizationService, + InviteService, +} from '@rocket.chat/federation-sdk'; +import { + canAccessResource, + isAuthenticated, +} from '@rocket.chat/homeserver/middlewares'; 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( - '/_matrix/federation/v2/invite/:roomId/:eventId', - async ({ body, params: { roomId, eventId } }) => { - return inviteService.processInvite( - body.event, - roomId as RoomID, - eventId as EventID, - body.room_version, - ); - }, - { - params: ProcessInviteParamsDto, - body: t.Object({ - event: t.Any(), - room_version: RoomVersionDto, - invite_room_state: t.Any(), - }), - detail: { - tags: ['Federation'], - summary: 'Process room invite', - description: 'Process an invite event from another Matrix server', + const eventAuthService = container.resolve(EventAuthorizationService); + + return app + .use(isAuthenticated(eventAuthService)) + .use(canAccessResource(eventAuthService)) + .put( + '/_matrix/federation/v2/invite/:roomId/:eventId', + async ({ body, params: { roomId, eventId } }) => { + return inviteService.processInvite( + body.event, + roomId as RoomID, + eventId as EventID, + body.room_version, + ); + }, + { + params: ProcessInviteParamsDto, + body: t.Object({ + event: t.Any(), + room_version: RoomVersionDto, + invite_room_state: t.Any(), + }), + detail: { + tags: ['Federation'], + summary: 'Process room invite', + description: 'Process an invite event from another Matrix server', + }, }, - }, - ); + ); }; diff --git a/packages/homeserver/src/controllers/federation/media.controller.ts b/packages/homeserver/src/controllers/federation/media.controller.ts index e951aabd7..9553e0e01 100644 --- a/packages/homeserver/src/controllers/federation/media.controller.ts +++ b/packages/homeserver/src/controllers/federation/media.controller.ts @@ -1,4 +1,10 @@ +import { EventAuthorizationService } from '@rocket.chat/federation-sdk'; +import { + canAccessResource, + isAuthenticated, +} from '@rocket.chat/homeserver/middlewares'; import { Elysia, t } from 'elysia'; +import { container } from 'tsyringe'; const ErrorResponseSchema = t.Object({ errcode: t.Literal('M_UNRECOGNIZED'), @@ -11,134 +17,138 @@ const ErrorResponseSchema = t.Object({ * All the medias are being handled by the Rocket.Chat instances. */ export const mediaPlugin = (app: Elysia) => { - return app.group('/_matrix', (app) => - app - .get( - '/federation/v1/media/download/:mediaId', - async ({ set }) => { - set.status = 404; - return { - errcode: 'M_UNRECOGNIZED', - error: 'This endpoint is not implemented on homeserver side', - }; - }, - { - params: t.Object({ - mediaId: t.String(), - }), - response: { - 404: ErrorResponseSchema, - }, - }, - ) + const eventAuthService = container.resolve(EventAuthorizationService); - .get( - '/media/r0/download/:serverName/:mediaId', - async ({ set }) => { - set.status = 404; - return { - errcode: 'M_UNRECOGNIZED', - error: 'This endpoint is not implemented on homeserver side', - }; + return app + .get( + '/_matrix/media/v3/config', + async ({ set }) => { + set.status = 404; + return { + errcode: 'M_UNRECOGNIZED', + error: 'This endpoint is not implemented on homeserver side', + }; + }, + { + response: { + 404: ErrorResponseSchema, }, - { - params: t.Object({ - serverName: t.String(), - mediaId: t.String(), - }), - response: { - 404: ErrorResponseSchema, + detail: { + tags: ['Media'], + summary: 'Get media configuration', + description: 'Get the media configuration for the homeserver', + }, + }, + ) + .group('/_matrix', (app) => + app + .use(isAuthenticated(eventAuthService)) + .use(canAccessResource(eventAuthService)) + .get( + '/federation/v1/media/download/:mediaId', + async ({ set }) => { + set.status = 404; + return { + errcode: 'M_UNRECOGNIZED', + error: 'This endpoint is not implemented on homeserver side', + }; }, - detail: { - tags: ['Media'], - summary: 'Download media', - description: 'Download a file from the Matrix media repository', + { + params: t.Object({ + mediaId: t.String(), + }), + response: { + 404: ErrorResponseSchema, + }, }, - }, - ) + ) - .get( - '/media/v3/download/:serverName/:mediaId', - async ({ set }) => { - set.status = 404; - return { - errcode: 'M_UNRECOGNIZED', - error: 'This endpoint is not implemented on homeserver side', - }; - }, - { - params: t.Object({ - serverName: t.String(), - mediaId: t.String(), - }), - query: t.Object({ - allow_remote: t.Optional(t.Boolean()), - timeout_ms: t.Optional(t.Number()), - }), - response: { - 404: ErrorResponseSchema, + .get( + '/media/r0/download/:serverName/:mediaId', + async ({ set }) => { + set.status = 404; + return { + errcode: 'M_UNRECOGNIZED', + error: 'This endpoint is not implemented on homeserver side', + }; }, - detail: { - tags: ['Media'], - summary: 'Download media', - description: 'Download a file from the Matrix media repository', + { + params: t.Object({ + serverName: t.String(), + mediaId: t.String(), + }), + response: { + 404: ErrorResponseSchema, + }, + detail: { + tags: ['Media'], + summary: 'Download media', + description: 'Download a file from the Matrix media repository', + }, }, - }, - ) + ) - .get( - '/media/v3/thumbnail/:serverName/:mediaId', - async ({ set }) => { - set.status = 404; - return { - errcode: 'M_UNRECOGNIZED', - error: 'This endpoint is not implemented on homeserver side', - }; - }, - { - params: t.Object({ - serverName: t.String(), - mediaId: t.String(), - }), - query: t.Object({ - width: t.Optional(t.Number({ minimum: 1, maximum: 800 })), - height: t.Optional(t.Number({ minimum: 1, maximum: 600 })), - method: t.Optional( - t.Union([t.Literal('crop'), t.Literal('scale')]), - ), - allow_remote: t.Optional(t.Boolean()), - timeout_ms: t.Optional(t.Number()), - }), - response: { - 404: ErrorResponseSchema, + .get( + '/media/v3/download/:serverName/:mediaId', + async ({ set }) => { + set.status = 404; + return { + errcode: 'M_UNRECOGNIZED', + error: 'This endpoint is not implemented on homeserver side', + }; }, - detail: { - tags: ['Media'], - summary: 'Get media thumbnail', - description: 'Get a thumbnail for a media file', + { + params: t.Object({ + serverName: t.String(), + mediaId: t.String(), + }), + query: t.Object({ + allow_remote: t.Optional(t.Boolean()), + timeout_ms: t.Optional(t.Number()), + }), + response: { + 404: ErrorResponseSchema, + }, + detail: { + tags: ['Media'], + summary: 'Download media', + description: 'Download a file from the Matrix media repository', + }, }, - }, - ) + ) - .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, + .get( + '/media/v3/thumbnail/:serverName/:mediaId', + async ({ set }) => { + set.status = 404; + return { + errcode: 'M_UNRECOGNIZED', + error: 'This endpoint is not implemented on homeserver side', + }; }, - detail: { - tags: ['Media'], - summary: 'Get media configuration', - description: 'Get the media configuration for the homeserver', + { + params: t.Object({ + serverName: t.String(), + mediaId: t.String(), + }), + query: t.Object({ + width: t.Optional(t.Number({ minimum: 1, maximum: 800 })), + height: t.Optional(t.Number({ minimum: 1, maximum: 600 })), + method: t.Optional( + t.Union([t.Literal('crop'), t.Literal('scale')]), + ), + allow_remote: t.Optional(t.Boolean()), + timeout_ms: t.Optional(t.Number()), + }), + response: { + 404: ErrorResponseSchema, + }, + detail: { + tags: ['Media'], + summary: 'Get media thumbnail', + description: 'Get a thumbnail for a media file', + }, }, - }, - ), - ); + ), + ); }; diff --git a/packages/homeserver/src/controllers/federation/profiles.controller.ts b/packages/homeserver/src/controllers/federation/profiles.controller.ts index b971dd243..dcba74d99 100644 --- a/packages/homeserver/src/controllers/federation/profiles.controller.ts +++ b/packages/homeserver/src/controllers/federation/profiles.controller.ts @@ -1,11 +1,17 @@ import { EventID, RoomID, - type RoomVersion, UserID, } from '@rocket.chat/federation-room'; -import { ProfilesService } from '@rocket.chat/federation-sdk'; -import { Elysia, t } from 'elysia'; +import { + EventAuthorizationService, + ProfilesService, +} from '@rocket.chat/federation-sdk'; +import { + canAccessResource, + isAuthenticated, +} from '@rocket.chat/homeserver/middlewares'; +import { Elysia } from 'elysia'; import { container } from 'tsyringe'; import { ErrorResponseDto, @@ -16,12 +22,6 @@ import { GetMissingEventsBodyDto, GetMissingEventsParamsDto, GetMissingEventsResponseDto, - GetStateIdsParamsDto, - GetStateIdsQueryDto, - GetStateIdsResponseDto, - GetStateParamsDto, - GetStateQueryDto, - GetStateResponseDto, MakeJoinParamsDto, MakeJoinQueryDto, MakeJoinResponseDto, @@ -33,8 +33,11 @@ import { export const profilesPlugin = (app: Elysia) => { const profilesService = container.resolve(ProfilesService); + const eventAuthService = container.resolve(EventAuthorizationService); return app + .use(isAuthenticated(eventAuthService)) + .use(canAccessResource(eventAuthService)) .get( '/_matrix/federation/v1/query/profile', ({ query: { user_id } }) => diff --git a/packages/homeserver/src/controllers/federation/transactions.controller.ts b/packages/homeserver/src/controllers/federation/transactions.controller.ts index e258dd5f8..6fffc1a8d 100644 --- a/packages/homeserver/src/controllers/federation/transactions.controller.ts +++ b/packages/homeserver/src/controllers/federation/transactions.controller.ts @@ -18,7 +18,7 @@ import { SendTransactionBodyDto, SendTransactionResponseDto, } from '../../dtos'; -import { canAccessEvent } from '../../middlewares/acl.middleware'; +import { canAccessResource, isAuthenticated } from '../../middlewares'; export const transactionsPlugin = (app: Elysia) => { const eventService = container.resolve(EventService); @@ -26,6 +26,8 @@ export const transactionsPlugin = (app: Elysia) => { const eventAuthService = container.resolve(EventAuthorizationService); return app + .use(isAuthenticated(eventAuthService)) + .use(canAccessResource(eventAuthService)) .put( '/_matrix/federation/v1/send/:txnId', async ({ body }) => { @@ -72,7 +74,6 @@ export const transactionsPlugin = (app: Elysia) => { }; }, { - use: canAccessEvent(eventAuthService), params: GetEventParamsDto, response: { 200: GetEventResponseDto, 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..32347e40f --- /dev/null +++ b/packages/homeserver/src/middlewares/canAccessResource.ts @@ -0,0 +1,66 @@ +import type { EventAuthorizationService } from '@rocket.chat/federation-sdk'; +import { errCodes } from '@rocket.chat/federation-sdk'; +import Elysia from 'elysia'; + +type RoutesParams = { roomId?: string; mediaId?: string; eventId?: string }; + +function extractEntityId( + params: RoutesParams, +): { type: 'event' | 'media' | 'room'; id: string } | undefined { + if (params.eventId) { + return { type: 'event', id: params.eventId }; + } + + if (params.mediaId) { + return { type: 'media', id: params.mediaId }; + } + + if (params.roomId) { + return { type: 'room', id: params.roomId }; + } + + return; +} + +export const canAccessResource = ( + federationAuth: EventAuthorizationService, +) => { + return new Elysia({ + name: 'homeserver/canAccessEvent', + // TODO: Get rid of any type + }).onBeforeHandle(async ({ params, authenticatedServer, set }: any) => { + try { + if (!authenticatedServer) { + set.status = errCodes.M_UNAUTHORIZED.status; + return { + errcode: errCodes.M_UNAUTHORIZED.errcode, + error: 'Authentication required', + }; + } + + const entity = extractEntityId(params); + if (!entity) { + return; + } + + const resourceAccess = await federationAuth.canAccessResource( + entity.type, + entity.id, + 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/index.ts b/packages/homeserver/src/middlewares/index.ts new file mode 100644 index 000000000..f319d4568 --- /dev/null +++ b/packages/homeserver/src/middlewares/index.ts @@ -0,0 +1,2 @@ +export { isAuthenticated } from './isAuthenticated'; +export { canAccessResource } from './canAccessResource'; diff --git a/packages/homeserver/src/middlewares/isAuthenticated.ts b/packages/homeserver/src/middlewares/isAuthenticated.ts new file mode 100644 index 000000000..2ecab5a86 --- /dev/null +++ b/packages/homeserver/src/middlewares/isAuthenticated.ts @@ -0,0 +1,68 @@ +import type { EventAuthorizationService } from '@rocket.chat/federation-sdk'; +import Elysia from 'elysia'; + +export const isAuthenticated = (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: null, + }; + } + + try { + let body: Record | undefined; + if (request.body) { + try { + const text = await request.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: null, + }; + } + + return { + authenticatedServer: isValid, + }; + } catch (error) { + console.error('Authentication error:', error); + set.status = 500; + return { + authenticatedServer: null, + }; + } + }) + .onBeforeHandle(({ authenticatedServer, set }) => { + if (!authenticatedServer) { + return { + errcode: set.status === 500 ? 'M_UNKNOWN' : 'M_UNAUTHORIZED', + error: + set.status === 500 + ? 'Internal server error' + : 'Authentication required', + }; + } + }); +}; From 937051e25f1d22ef42c91f37dcbbe550513fb114 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 30 Sep 2025 08:51:58 -0300 Subject: [PATCH 02/20] remove barrel imports from middlewares --- .../federation/invite.controller.ts | 6 ++--- .../federation/media.controller.ts | 6 ++--- .../federation/profiles.controller.ts | 6 ++--- .../federation/send-join.controller.ts | 27 ++++++++++++------- .../federation/state.controller.ts | 10 ++++++- .../federation/transactions.controller.ts | 3 ++- packages/homeserver/src/middlewares/index.ts | 2 -- 7 files changed, 35 insertions(+), 25 deletions(-) delete mode 100644 packages/homeserver/src/middlewares/index.ts diff --git a/packages/homeserver/src/controllers/federation/invite.controller.ts b/packages/homeserver/src/controllers/federation/invite.controller.ts index e43fb73de..f0ab171ab 100644 --- a/packages/homeserver/src/controllers/federation/invite.controller.ts +++ b/packages/homeserver/src/controllers/federation/invite.controller.ts @@ -3,10 +3,8 @@ import { EventAuthorizationService, InviteService, } from '@rocket.chat/federation-sdk'; -import { - canAccessResource, - isAuthenticated, -} from '@rocket.chat/homeserver/middlewares'; +import { canAccessResource } from '@rocket.chat/homeserver/middlewares/canAccessResource'; +import { isAuthenticated } from '@rocket.chat/homeserver/middlewares/isAuthenticated'; import { Elysia, t } from 'elysia'; import { container } from 'tsyringe'; import { ProcessInviteParamsDto, RoomVersionDto } from '../../dtos'; diff --git a/packages/homeserver/src/controllers/federation/media.controller.ts b/packages/homeserver/src/controllers/federation/media.controller.ts index 9553e0e01..6fa323b58 100644 --- a/packages/homeserver/src/controllers/federation/media.controller.ts +++ b/packages/homeserver/src/controllers/federation/media.controller.ts @@ -1,8 +1,6 @@ import { EventAuthorizationService } from '@rocket.chat/federation-sdk'; -import { - canAccessResource, - isAuthenticated, -} from '@rocket.chat/homeserver/middlewares'; +import { canAccessResource } from '@rocket.chat/homeserver/middlewares/canAccessResource'; +import { isAuthenticated } from '@rocket.chat/homeserver/middlewares/isAuthenticated'; import { Elysia, t } from 'elysia'; import { container } from 'tsyringe'; diff --git a/packages/homeserver/src/controllers/federation/profiles.controller.ts b/packages/homeserver/src/controllers/federation/profiles.controller.ts index dcba74d99..3652acd61 100644 --- a/packages/homeserver/src/controllers/federation/profiles.controller.ts +++ b/packages/homeserver/src/controllers/federation/profiles.controller.ts @@ -7,10 +7,8 @@ import { EventAuthorizationService, ProfilesService, } from '@rocket.chat/federation-sdk'; -import { - canAccessResource, - isAuthenticated, -} from '@rocket.chat/homeserver/middlewares'; +import { canAccessResource } from '@rocket.chat/homeserver/middlewares/canAccessResource'; +import { isAuthenticated } from '@rocket.chat/homeserver/middlewares/isAuthenticated'; import { Elysia } from 'elysia'; import { container } from 'tsyringe'; import { diff --git a/packages/homeserver/src/controllers/federation/send-join.controller.ts b/packages/homeserver/src/controllers/federation/send-join.controller.ts index 45562656a..046531800 100644 --- a/packages/homeserver/src/controllers/federation/send-join.controller.ts +++ b/packages/homeserver/src/controllers/federation/send-join.controller.ts @@ -1,5 +1,10 @@ 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 { canAccessResource } from '@rocket.chat/homeserver/middlewares/canAccessResource'; +import { isAuthenticated } from '@rocket.chat/homeserver/middlewares/isAuthenticated'; import { Elysia, t } from 'elysia'; import { container } from 'tsyringe'; import { @@ -10,15 +15,19 @@ import { export const sendJoinPlugin = (app: Elysia) => { const sendJoinService = container.resolve(SendJoinService); + const eventAuthService = container.resolve(EventAuthorizationService); - return app.put( - '/_matrix/federation/v2/send_join/:roomId/:eventId', - async ({ - params, - body, - query: _query, // not destructuring this breaks the endpoint - }) => { - const { roomId, eventId } = params; + return app + .use(isAuthenticated(eventAuthService)) + .use(canAccessResource(eventAuthService)) + .put( + '/_matrix/federation/v2/send_join/:roomId/:eventId', + async ({ + params, + body, + query: _query, // not destructuring this breaks the endpoint + }) => { + const { roomId, eventId } = params; return sendJoinService.sendJoin( roomId as RoomID, diff --git a/packages/homeserver/src/controllers/federation/state.controller.ts b/packages/homeserver/src/controllers/federation/state.controller.ts index 42680612d..3fa37c458 100644 --- a/packages/homeserver/src/controllers/federation/state.controller.ts +++ b/packages/homeserver/src/controllers/federation/state.controller.ts @@ -1,5 +1,10 @@ import { EventID, RoomID } from '@rocket.chat/federation-room'; -import { EventService } from '@rocket.chat/federation-sdk'; +import { + EventAuthorizationService, + EventService, +} from '@rocket.chat/federation-sdk'; +import { canAccessResource } from '@rocket.chat/homeserver/middlewares/canAccessResource'; +import { isAuthenticated } from '@rocket.chat/homeserver/middlewares/isAuthenticated'; import { Elysia } from 'elysia'; import { container } from 'tsyringe'; import { @@ -14,8 +19,11 @@ import { export const statePlugin = (app: Elysia) => { const eventService = container.resolve(EventService); + const eventAuthService = container.resolve(EventAuthorizationService); return app + .use(isAuthenticated(eventAuthService)) + .use(canAccessResource(eventAuthService)) .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 6fffc1a8d..03f4f7179 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 { canAccessResource } from '@rocket.chat/homeserver/middlewares/canAccessResource'; +import { isAuthenticated } 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 { canAccessResource, isAuthenticated } from '../../middlewares'; export const transactionsPlugin = (app: Elysia) => { const eventService = container.resolve(EventService); diff --git a/packages/homeserver/src/middlewares/index.ts b/packages/homeserver/src/middlewares/index.ts deleted file mode 100644 index f319d4568..000000000 --- a/packages/homeserver/src/middlewares/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { isAuthenticated } from './isAuthenticated'; -export { canAccessResource } from './canAccessResource'; From fabfc704ee7901f01984a52753319978fc8397c3 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 30 Sep 2025 12:11:00 -0300 Subject: [PATCH 03/20] refactor canAccessResource middleware --- .../federation/invite.controller.ts | 52 +++-- .../federation/media.controller.ts | 7 +- .../federation/profiles.controller.ts | 122 ++++++----- .../federation/rooms.controller.ts | 199 +++++++++--------- .../federation/send-join.controller.ts | 22 +- .../federation/state.controller.ts | 6 +- .../federation/transactions.controller.ts | 11 +- .../src/middlewares/canAccessResource.ts | 104 +++++---- .../src/middlewares/isAuthenticated.ts | 4 +- 9 files changed, 279 insertions(+), 248 deletions(-) diff --git a/packages/homeserver/src/controllers/federation/invite.controller.ts b/packages/homeserver/src/controllers/federation/invite.controller.ts index f0ab171ab..5c2713858 100644 --- a/packages/homeserver/src/controllers/federation/invite.controller.ts +++ b/packages/homeserver/src/controllers/federation/invite.controller.ts @@ -3,8 +3,7 @@ import { EventAuthorizationService, InviteService, } from '@rocket.chat/federation-sdk'; -import { canAccessResource } from '@rocket.chat/homeserver/middlewares/canAccessResource'; -import { isAuthenticated } from '@rocket.chat/homeserver/middlewares/isAuthenticated'; +import { canAccessResourceMiddleware } from '@rocket.chat/homeserver/middlewares/canAccessResource'; import { Elysia, t } from 'elysia'; import { container } from 'tsyringe'; import { ProcessInviteParamsDto, RoomVersionDto } from '../../dtos'; @@ -13,31 +12,28 @@ export const invitePlugin = (app: Elysia) => { const inviteService = container.resolve(InviteService); const eventAuthService = container.resolve(EventAuthorizationService); - return app - .use(isAuthenticated(eventAuthService)) - .use(canAccessResource(eventAuthService)) - .put( - '/_matrix/federation/v2/invite/:roomId/:eventId', - async ({ body, params: { roomId, eventId } }) => { - return inviteService.processInvite( - body.event, - roomId as RoomID, - eventId as EventID, - body.room_version, - ); + return app.use(canAccessResourceMiddleware(eventAuthService, 'room')).put( + '/_matrix/federation/v2/invite/:roomId/:eventId', + async ({ body, params: { roomId, eventId } }) => { + return inviteService.processInvite( + body.event, + roomId as RoomID, + eventId as EventID, + body.room_version, + ) + }, + { + params: ProcessInviteParamsDto, + body: t.Object({ + event: t.Any(), + room_version: RoomVersionDto, + invite_room_state: t.Any(), + }), + detail: { + tags: ['Federation'], + summary: 'Process room invite', + description: 'Process an invite event from another Matrix server', }, - { - params: ProcessInviteParamsDto, - body: t.Object({ - event: t.Any(), - room_version: RoomVersionDto, - invite_room_state: t.Any(), - }), - detail: { - tags: ['Federation'], - summary: 'Process room invite', - description: 'Process an invite event from another Matrix server', - }, - }, - ); + }, + ); }; diff --git a/packages/homeserver/src/controllers/federation/media.controller.ts b/packages/homeserver/src/controllers/federation/media.controller.ts index 6fa323b58..8152eb168 100644 --- a/packages/homeserver/src/controllers/federation/media.controller.ts +++ b/packages/homeserver/src/controllers/federation/media.controller.ts @@ -1,6 +1,6 @@ import { EventAuthorizationService } from '@rocket.chat/federation-sdk'; -import { canAccessResource } from '@rocket.chat/homeserver/middlewares/canAccessResource'; -import { isAuthenticated } from '@rocket.chat/homeserver/middlewares/isAuthenticated'; +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'; @@ -40,8 +40,7 @@ export const mediaPlugin = (app: Elysia) => { ) .group('/_matrix', (app) => app - .use(isAuthenticated(eventAuthService)) - .use(canAccessResource(eventAuthService)) + .use(canAccessResourceMiddleware(eventAuthService, 'media')) .get( '/federation/v1/media/download/:mediaId', async ({ set }) => { diff --git a/packages/homeserver/src/controllers/federation/profiles.controller.ts b/packages/homeserver/src/controllers/federation/profiles.controller.ts index 3652acd61..729fd3d88 100644 --- a/packages/homeserver/src/controllers/federation/profiles.controller.ts +++ b/packages/homeserver/src/controllers/federation/profiles.controller.ts @@ -7,9 +7,9 @@ import { EventAuthorizationService, ProfilesService, } from '@rocket.chat/federation-sdk'; -import { canAccessResource } from '@rocket.chat/homeserver/middlewares/canAccessResource'; -import { isAuthenticated } from '@rocket.chat/homeserver/middlewares/isAuthenticated'; -import { Elysia } from 'elysia'; +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 { ErrorResponseDto, @@ -34,54 +34,76 @@ export const profilesPlugin = (app: Elysia) => { const eventAuthService = container.resolve(EventAuthorizationService); return app - .use(isAuthenticated(eventAuthService)) - .use(canAccessResource(eventAuthService)) - .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( + '/_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 ({ 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( + '/_matrix/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..03ed6d06b 100644 --- a/packages/homeserver/src/controllers/federation/rooms.controller.ts +++ b/packages/homeserver/src/controllers/federation/rooms.controller.ts @@ -5,113 +5,112 @@ import { container } from 'tsyringe'; export const roomPlugin = (app: Elysia) => { const stateService = container.resolve(StateService); - 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 + .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 046531800..f02dca526 100644 --- a/packages/homeserver/src/controllers/federation/send-join.controller.ts +++ b/packages/homeserver/src/controllers/federation/send-join.controller.ts @@ -3,8 +3,7 @@ import { EventAuthorizationService, SendJoinService, } from '@rocket.chat/federation-sdk'; -import { canAccessResource } from '@rocket.chat/homeserver/middlewares/canAccessResource'; -import { isAuthenticated } from '@rocket.chat/homeserver/middlewares/isAuthenticated'; +import { canAccessResourceMiddleware } from '@rocket.chat/homeserver/middlewares/canAccessResource'; import { Elysia, t } from 'elysia'; import { container } from 'tsyringe'; import { @@ -17,17 +16,14 @@ export const sendJoinPlugin = (app: Elysia) => { const sendJoinService = container.resolve(SendJoinService); const eventAuthService = container.resolve(EventAuthorizationService); - return app - .use(isAuthenticated(eventAuthService)) - .use(canAccessResource(eventAuthService)) - .put( - '/_matrix/federation/v2/send_join/:roomId/:eventId', - async ({ - params, - body, - query: _query, // not destructuring this breaks the endpoint - }) => { - const { roomId, eventId } = params; + return app.use(canAccessResourceMiddleware(eventAuthService, 'room')).put( + '/_matrix/federation/v2/send_join/:roomId/:eventId', + async ({ + params, + body, + query: _query, // not destructuring this breaks the endpoint + }) => { + const { roomId, eventId } = params; return sendJoinService.sendJoin( roomId as RoomID, diff --git a/packages/homeserver/src/controllers/federation/state.controller.ts b/packages/homeserver/src/controllers/federation/state.controller.ts index 3fa37c458..33ece4bc3 100644 --- a/packages/homeserver/src/controllers/federation/state.controller.ts +++ b/packages/homeserver/src/controllers/federation/state.controller.ts @@ -3,8 +3,7 @@ import { EventAuthorizationService, EventService, } from '@rocket.chat/federation-sdk'; -import { canAccessResource } from '@rocket.chat/homeserver/middlewares/canAccessResource'; -import { isAuthenticated } from '@rocket.chat/homeserver/middlewares/isAuthenticated'; +import { canAccessResourceMiddleware } from '@rocket.chat/homeserver/middlewares/canAccessResource'; import { Elysia } from 'elysia'; import { container } from 'tsyringe'; import { @@ -22,8 +21,7 @@ export const statePlugin = (app: Elysia) => { const eventAuthService = container.resolve(EventAuthorizationService); return app - .use(isAuthenticated(eventAuthService)) - .use(canAccessResource(eventAuthService)) + .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 03f4f7179..c9989a073 100644 --- a/packages/homeserver/src/controllers/federation/transactions.controller.ts +++ b/packages/homeserver/src/controllers/federation/transactions.controller.ts @@ -4,8 +4,8 @@ import { EventAuthorizationService, EventService, } from '@rocket.chat/federation-sdk'; -import { canAccessResource } from '@rocket.chat/homeserver/middlewares/canAccessResource'; -import { isAuthenticated } from '@rocket.chat/homeserver/middlewares/isAuthenticated'; +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 { @@ -27,8 +27,6 @@ export const transactionsPlugin = (app: Elysia) => { const eventAuthService = container.resolve(EventAuthorizationService); return app - .use(isAuthenticated(eventAuthService)) - .use(canAccessResource(eventAuthService)) .put( '/_matrix/federation/v1/send/:txnId', async ({ body }) => { @@ -42,6 +40,7 @@ export const transactionsPlugin = (app: Elysia) => { }; }, { + use: isAuthenticatedMiddleware(eventAuthService), body: SendTransactionBodyDto, response: { 200: SendTransactionResponseDto, @@ -54,6 +53,7 @@ export const transactionsPlugin = (app: Elysia) => { }, }, ) + .get( '/_matrix/federation/v1/event/:eventId', async ({ params, set }) => { @@ -75,6 +75,7 @@ export const transactionsPlugin = (app: Elysia) => { }; }, { + use: isAuthenticatedMiddleware(eventAuthService), params: GetEventParamsDto, response: { 200: GetEventResponseDto, @@ -90,6 +91,7 @@ export const transactionsPlugin = (app: Elysia) => { }, }, ) + .get( '/_matrix/federation/v1/backfill/:roomId', async ({ params, query, set }) => { @@ -122,6 +124,7 @@ export const transactionsPlugin = (app: Elysia) => { } }, { + use: isAuthenticatedMiddleware(eventAuthService), params: BackfillParamsDto, query: BackfillQueryDto, response: { diff --git a/packages/homeserver/src/middlewares/canAccessResource.ts b/packages/homeserver/src/middlewares/canAccessResource.ts index 32347e40f..f73db8113 100644 --- a/packages/homeserver/src/middlewares/canAccessResource.ts +++ b/packages/homeserver/src/middlewares/canAccessResource.ts @@ -1,66 +1,82 @@ import type { EventAuthorizationService } from '@rocket.chat/federation-sdk'; import { errCodes } from '@rocket.chat/federation-sdk'; import Elysia from 'elysia'; +import { isAuthenticatedMiddleware } from './isAuthenticated'; -type RoutesParams = { roomId?: string; mediaId?: string; eventId?: string }; +type RoutesParams = { + roomId?: string; + mediaId?: string; + eventId?: string; +}; function extractEntityId( params: RoutesParams, -): { type: 'event' | 'media' | 'room'; id: string } | undefined { - if (params.eventId) { - return { type: 'event', id: params.eventId }; + entityType: 'event' | 'media' | 'room', +): string { + if (entityType === 'room') { + const roomId = params.roomId; + if (!roomId) { + throw new Error('Room ID is required'); + } + + return roomId; } - if (params.mediaId) { - return { type: 'media', id: params.mediaId }; + if (entityType === 'media') { + const mediaId = params.mediaId; + if (!mediaId) { + throw new Error('Media ID is required'); + } + + return mediaId; } - if (params.roomId) { - return { type: 'room', id: params.roomId }; + if (entityType === 'event') { + const eventId = params.eventId; + if (!eventId) { + throw new Error('Event ID is required'); + } + + return eventId; } - return; + throw new Error('Invalid entity type'); } -export const canAccessResource = ( +export const canAccessResourceMiddleware = ( federationAuth: EventAuthorizationService, + entityType: 'event' | 'media' | 'room', ) => { - return new Elysia({ - name: 'homeserver/canAccessEvent', - // TODO: Get rid of any type - }).onBeforeHandle(async ({ params, authenticatedServer, set }: any) => { - try { - if (!authenticatedServer) { - set.status = errCodes.M_UNAUTHORIZED.status; - return { - errcode: errCodes.M_UNAUTHORIZED.errcode, - error: 'Authentication required', - }; - } + return new Elysia({ name: 'homeserver/canAccessEvent' }) + .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 entity = extractEntityId(params); - if (!entity) { - return; - } - - const resourceAccess = await federationAuth.canAccessResource( - entity.type, - entity.id, - authenticatedServer, - ); - if (!resourceAccess) { - set.status = errCodes.M_FORBIDDEN.status; + const resourceAccess = await federationAuth.canAccessResource( + entityType, + extractEntityId(params, entityType), + 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_FORBIDDEN.errcode, - error: 'Access denied to resource', + errcode: errCodes.M_UNKNOWN.errcode, + error: 'Internal server error', }; } - } 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 index 2ecab5a86..3f469c862 100644 --- a/packages/homeserver/src/middlewares/isAuthenticated.ts +++ b/packages/homeserver/src/middlewares/isAuthenticated.ts @@ -1,7 +1,9 @@ import type { EventAuthorizationService } from '@rocket.chat/federation-sdk'; import Elysia from 'elysia'; -export const isAuthenticated = (federationAuth: EventAuthorizationService) => { +export const isAuthenticatedMiddleware = ( + federationAuth: EventAuthorizationService, +) => { return new Elysia({ name: 'homeserver/isAuthenticated', }) From cbc61124052ef838625498b4882e4f9bdaa5b1eb Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 1 Oct 2025 07:09:30 -0300 Subject: [PATCH 04/20] add room invite check on canAccessResource validation --- .../src/services/event-authorization.service.ts | 16 ++++++++++++++++ .../src/middlewares/canAccessResource.ts | 8 +------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/federation-sdk/src/services/event-authorization.service.ts b/packages/federation-sdk/src/services/event-authorization.service.ts index 6a3cf96d1..2c30276ff 100644 --- a/packages/federation-sdk/src/services/event-authorization.service.ts +++ b/packages/federation-sdk/src/services/event-authorization.service.ts @@ -267,6 +267,22 @@ export class EventAuthorizationService { 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 === 'invite' && stateKey) { + 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() && diff --git a/packages/homeserver/src/middlewares/canAccessResource.ts b/packages/homeserver/src/middlewares/canAccessResource.ts index f73db8113..e79b70615 100644 --- a/packages/homeserver/src/middlewares/canAccessResource.ts +++ b/packages/homeserver/src/middlewares/canAccessResource.ts @@ -3,14 +3,8 @@ import { errCodes } from '@rocket.chat/federation-sdk'; import Elysia from 'elysia'; import { isAuthenticatedMiddleware } from './isAuthenticated'; -type RoutesParams = { - roomId?: string; - mediaId?: string; - eventId?: string; -}; - function extractEntityId( - params: RoutesParams, + params: { roomId?: string; mediaId?: string; eventId?: string }, entityType: 'event' | 'media' | 'room', ): string { if (entityType === 'room') { From 431eb4065c29a1f49a13fa3386cb894b3f125ee1 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 6 Oct 2025 11:15:32 -0300 Subject: [PATCH 05/20] lint --- .../src/controllers/federation/invite.controller.ts | 2 +- .../src/controllers/federation/profiles.controller.ts | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/homeserver/src/controllers/federation/invite.controller.ts b/packages/homeserver/src/controllers/federation/invite.controller.ts index 5c2713858..fe3da48f8 100644 --- a/packages/homeserver/src/controllers/federation/invite.controller.ts +++ b/packages/homeserver/src/controllers/federation/invite.controller.ts @@ -20,7 +20,7 @@ export const invitePlugin = (app: Elysia) => { roomId as RoomID, eventId as EventID, body.room_version, - ) + ); }, { params: ProcessInviteParamsDto, diff --git a/packages/homeserver/src/controllers/federation/profiles.controller.ts b/packages/homeserver/src/controllers/federation/profiles.controller.ts index 729fd3d88..d9370536d 100644 --- a/packages/homeserver/src/controllers/federation/profiles.controller.ts +++ b/packages/homeserver/src/controllers/federation/profiles.controller.ts @@ -1,8 +1,4 @@ -import { - EventID, - RoomID, - UserID, -} from '@rocket.chat/federation-room'; +import { EventID, RoomID, UserID } from '@rocket.chat/federation-room'; import { EventAuthorizationService, ProfilesService, @@ -39,7 +35,8 @@ export const profilesPlugin = (app: Elysia) => { .use(isAuthenticatedMiddleware(eventAuthService)) .get( '/_matrix/federation/v1/query/profile', - ({ query: { user_id } }) => profilesService.queryProfile(user_id as UserID), + ({ query: { user_id } }) => + profilesService.queryProfile(user_id as UserID), { query: QueryProfileQueryDto, response: { From f18dfa06083dff627fbcc0fc58f161954f25558f Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 6 Oct 2025 11:34:52 -0300 Subject: [PATCH 06/20] handle missing room state before accessing ACLs --- .../src/services/event-authorization.service.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/federation-sdk/src/services/event-authorization.service.ts b/packages/federation-sdk/src/services/event-authorization.service.ts index 2c30276ff..1f3a0bfac 100644 --- a/packages/federation-sdk/src/services/event-authorization.service.ts +++ b/packages/federation-sdk/src/services/event-authorization.service.ts @@ -251,6 +251,10 @@ export class EventAuthorizationService { serverName: string, ): Promise { const state = await this.stateService.getFullRoomState(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); From e58f2a0e2242c8ff1cedd99b5de9502c1fa6312b Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 6 Oct 2025 11:36:51 -0300 Subject: [PATCH 07/20] fix naming inconsistency in Elysia plugin name --- packages/homeserver/src/middlewares/canAccessResource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/homeserver/src/middlewares/canAccessResource.ts b/packages/homeserver/src/middlewares/canAccessResource.ts index e79b70615..6de612d3b 100644 --- a/packages/homeserver/src/middlewares/canAccessResource.ts +++ b/packages/homeserver/src/middlewares/canAccessResource.ts @@ -41,7 +41,7 @@ export const canAccessResourceMiddleware = ( federationAuth: EventAuthorizationService, entityType: 'event' | 'media' | 'room', ) => { - return new Elysia({ name: 'homeserver/canAccessEvent' }) + return new Elysia({ name: 'homeserver/canAccessResource' }) .use(isAuthenticatedMiddleware(federationAuth)) .onBeforeHandle(async ({ params, authenticatedServer, set }) => { try { From 936710aff74dcb0ed282cd6bd5da20c38197e2b3 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 6 Oct 2025 11:52:35 -0300 Subject: [PATCH 08/20] do not drain the request body in isAuthenticated middleware --- packages/homeserver/src/middlewares/isAuthenticated.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/homeserver/src/middlewares/isAuthenticated.ts b/packages/homeserver/src/middlewares/isAuthenticated.ts index 3f469c862..b552c40a8 100644 --- a/packages/homeserver/src/middlewares/isAuthenticated.ts +++ b/packages/homeserver/src/middlewares/isAuthenticated.ts @@ -24,7 +24,8 @@ export const isAuthenticatedMiddleware = ( let body: Record | undefined; if (request.body) { try { - const text = await request.text(); + const clone = request.clone(); + const text = await clone.text(); body = text ? JSON.parse(text) : undefined; } catch { body = undefined; From bc00895e938a5f9c9d10e8f878a816635b5af3e6 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 6 Oct 2025 12:05:24 -0300 Subject: [PATCH 09/20] fix path prefix duplication --- .../src/controllers/federation/profiles.controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/homeserver/src/controllers/federation/profiles.controller.ts b/packages/homeserver/src/controllers/federation/profiles.controller.ts index d9370536d..37b550be4 100644 --- a/packages/homeserver/src/controllers/federation/profiles.controller.ts +++ b/packages/homeserver/src/controllers/federation/profiles.controller.ts @@ -34,7 +34,7 @@ export const profilesPlugin = (app: Elysia) => { app .use(isAuthenticatedMiddleware(eventAuthService)) .get( - '/_matrix/federation/v1/query/profile', + '/federation/v1/query/profile', ({ query: { user_id } }) => profilesService.queryProfile(user_id as UserID), { @@ -50,7 +50,7 @@ export const profilesPlugin = (app: Elysia) => { }, ) .post( - '/_matrix/federation/v1/user/keys/query', + '/federation/v1/user/keys/query', async ({ set }) => { set.status = 501; return { @@ -75,7 +75,7 @@ export const profilesPlugin = (app: Elysia) => { }, ) .get( - '/_matrix/federation/v1/user/devices/:userId', + '/federation/v1/user/devices/:userId', async ({ set }) => { set.status = 501; return { From d4e58833d0085ab4fecd958e08603b1c234d9af0 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 6 Oct 2025 12:09:32 -0300 Subject: [PATCH 10/20] apply authentication middleware to the config endpoint --- .../federation/media.controller.ts | 238 +++++++++--------- 1 file changed, 119 insertions(+), 119 deletions(-) diff --git a/packages/homeserver/src/controllers/federation/media.controller.ts b/packages/homeserver/src/controllers/federation/media.controller.ts index 8152eb168..74259edf6 100644 --- a/packages/homeserver/src/controllers/federation/media.controller.ts +++ b/packages/homeserver/src/controllers/federation/media.controller.ts @@ -17,135 +17,135 @@ const ErrorResponseSchema = t.Object({ export const mediaPlugin = (app: Elysia) => { const eventAuthService = container.resolve(EventAuthorizationService); - return app - .get( - '/_matrix/media/v3/config', - async ({ set }) => { - set.status = 404; - return { - errcode: 'M_UNRECOGNIZED', - error: 'This endpoint is not implemented on homeserver side', - }; - }, - { - response: { - 404: ErrorResponseSchema, + 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', + }; }, - detail: { - tags: ['Media'], - summary: 'Get media configuration', - description: 'Get the media configuration for the homeserver', - }, - }, - ) - .group('/_matrix', (app) => - app - .use(canAccessResourceMiddleware(eventAuthService, 'media')) - .get( - '/federation/v1/media/download/:mediaId', - 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', }, - { - params: t.Object({ - mediaId: t.String(), - }), - response: { - 404: ErrorResponseSchema, - }, + }, + ) + .use(canAccessResourceMiddleware(eventAuthService, 'media')) + .get( + '/federation/v1/media/download/:mediaId', + async ({ set }) => { + set.status = 404; + return { + errcode: 'M_UNRECOGNIZED', + error: 'This endpoint is not implemented on homeserver side', + }; + }, + { + params: t.Object({ + mediaId: t.String(), + }), + response: { + 404: ErrorResponseSchema, }, - ) + }, + ) - .get( - '/media/r0/download/:serverName/:mediaId', - async ({ set }) => { - set.status = 404; - return { - errcode: 'M_UNRECOGNIZED', - error: 'This endpoint is not implemented on homeserver side', - }; + .get( + '/media/r0/download/:serverName/:mediaId', + async ({ set }) => { + set.status = 404; + return { + errcode: 'M_UNRECOGNIZED', + error: 'This endpoint is not implemented on homeserver side', + }; + }, + { + params: t.Object({ + serverName: t.String(), + mediaId: t.String(), + }), + response: { + 404: ErrorResponseSchema, }, - { - params: t.Object({ - serverName: t.String(), - mediaId: t.String(), - }), - response: { - 404: ErrorResponseSchema, - }, - detail: { - tags: ['Media'], - summary: 'Download media', - description: 'Download a file from the Matrix media repository', - }, + detail: { + tags: ['Media'], + summary: 'Download media', + description: 'Download a file from the Matrix media repository', }, - ) + }, + ) - .get( - '/media/v3/download/:serverName/:mediaId', - async ({ set }) => { - set.status = 404; - return { - errcode: 'M_UNRECOGNIZED', - error: 'This endpoint is not implemented on homeserver side', - }; + .get( + '/media/v3/download/:serverName/:mediaId', + async ({ set }) => { + set.status = 404; + return { + errcode: 'M_UNRECOGNIZED', + error: 'This endpoint is not implemented on homeserver side', + }; + }, + { + params: t.Object({ + serverName: t.String(), + mediaId: t.String(), + }), + query: t.Object({ + allow_remote: t.Optional(t.Boolean()), + timeout_ms: t.Optional(t.Number()), + }), + response: { + 404: ErrorResponseSchema, }, - { - params: t.Object({ - serverName: t.String(), - mediaId: t.String(), - }), - query: t.Object({ - allow_remote: t.Optional(t.Boolean()), - timeout_ms: t.Optional(t.Number()), - }), - response: { - 404: ErrorResponseSchema, - }, - detail: { - tags: ['Media'], - summary: 'Download media', - description: 'Download a file from the Matrix media repository', - }, + detail: { + tags: ['Media'], + summary: 'Download media', + description: 'Download a file from the Matrix media repository', }, - ) + }, + ) - .get( - '/media/v3/thumbnail/:serverName/:mediaId', - async ({ set }) => { - set.status = 404; - return { - errcode: 'M_UNRECOGNIZED', - error: 'This endpoint is not implemented on homeserver side', - }; + .get( + '/media/v3/thumbnail/:serverName/:mediaId', + async ({ set }) => { + set.status = 404; + return { + errcode: 'M_UNRECOGNIZED', + error: 'This endpoint is not implemented on homeserver side', + }; + }, + { + params: t.Object({ + serverName: t.String(), + mediaId: t.String(), + }), + query: t.Object({ + width: t.Optional(t.Number({ minimum: 1, maximum: 800 })), + height: t.Optional(t.Number({ minimum: 1, maximum: 600 })), + method: t.Optional( + t.Union([t.Literal('crop'), t.Literal('scale')]), + ), + allow_remote: t.Optional(t.Boolean()), + timeout_ms: t.Optional(t.Number()), + }), + response: { + 404: ErrorResponseSchema, }, - { - params: t.Object({ - serverName: t.String(), - mediaId: t.String(), - }), - query: t.Object({ - width: t.Optional(t.Number({ minimum: 1, maximum: 800 })), - height: t.Optional(t.Number({ minimum: 1, maximum: 600 })), - method: t.Optional( - t.Union([t.Literal('crop'), t.Literal('scale')]), - ), - allow_remote: t.Optional(t.Boolean()), - timeout_ms: t.Optional(t.Number()), - }), - response: { - 404: ErrorResponseSchema, - }, - detail: { - tags: ['Media'], - summary: 'Get media thumbnail', - description: 'Get a thumbnail for a media file', - }, + detail: { + tags: ['Media'], + summary: 'Get media thumbnail', + description: 'Get a thumbnail for a media file', }, - ), - ); + }, + ), + ); }; From 2416a289694d1843042ce587b32dd5f40caa6be4 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 6 Oct 2025 12:20:21 -0300 Subject: [PATCH 11/20] mitigate potential Regular Expression Denial of Service (ReDoS) vulnerability --- .../src/services/event-authorization.service.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/federation-sdk/src/services/event-authorization.service.ts b/packages/federation-sdk/src/services/event-authorization.service.ts index 1f3a0bfac..39a4e84b5 100644 --- a/packages/federation-sdk/src/services/event-authorization.service.ts +++ b/packages/federation-sdk/src/services/event-authorization.service.ts @@ -177,6 +177,11 @@ export class EventAuthorizationService { return true; } + if (pattern.length > 200 || (pattern.match(/[*?]/g) || []).length > 20) { + this.logger.warn(`ACL pattern too complex, rejecting: ${pattern}`); + return false; + } + let regexPattern = pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '.*') From ef5e6afd3cf07dd8c8a8f57361611e5517060681 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 6 Oct 2025 12:31:53 -0300 Subject: [PATCH 12/20] improve error handling and parameter validation in extractEntityId --- .../src/middlewares/canAccessResource.ts | 36 ++++++++----------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/packages/homeserver/src/middlewares/canAccessResource.ts b/packages/homeserver/src/middlewares/canAccessResource.ts index 6de612d3b..38485e1e5 100644 --- a/packages/homeserver/src/middlewares/canAccessResource.ts +++ b/packages/homeserver/src/middlewares/canAccessResource.ts @@ -6,35 +6,20 @@ import { isAuthenticatedMiddleware } from './isAuthenticated'; function extractEntityId( params: { roomId?: string; mediaId?: string; eventId?: string }, entityType: 'event' | 'media' | 'room', -): string { +): string | null { if (entityType === 'room') { - const roomId = params.roomId; - if (!roomId) { - throw new Error('Room ID is required'); - } - - return roomId; + return params.roomId ?? null; } if (entityType === 'media') { - const mediaId = params.mediaId; - if (!mediaId) { - throw new Error('Media ID is required'); - } - - return mediaId; + return params.mediaId ?? null; } if (entityType === 'event') { - const eventId = params.eventId; - if (!eventId) { - throw new Error('Event ID is required'); - } - - return eventId; + return params.eventId ?? null; } - throw new Error('Invalid entity type'); + return null; } export const canAccessResourceMiddleware = ( @@ -53,9 +38,18 @@ export const canAccessResourceMiddleware = ( }; } + 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, - extractEntityId(params, entityType), + resourceId, authenticatedServer, ); if (!resourceAccess) { From 73896fd95fe8606515c3ea48440abd649c3a92a4 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 6 Oct 2025 12:37:05 -0300 Subject: [PATCH 13/20] invite loop leaks when membership events are missing fields --- .../src/services/event-authorization.service.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/federation-sdk/src/services/event-authorization.service.ts b/packages/federation-sdk/src/services/event-authorization.service.ts index 39a4e84b5..f70a9d732 100644 --- a/packages/federation-sdk/src/services/event-authorization.service.ts +++ b/packages/federation-sdk/src/services/event-authorization.service.ts @@ -278,9 +278,14 @@ export class EventAuthorizationService { for (const [key, event] of state.entries()) { if (key.startsWith('m.room.member:') && event?.isMembershipEvent()) { - const membership = event.getContent().membership; + const membership = event.getContent()?.membership; const stateKey = event.stateKey; - if (membership === 'invite' && stateKey) { + + if (!membership || !stateKey || !stateKey.includes(':')) { + continue; + } + + if (membership === 'invite') { const invitedUserServer = stateKey.split(':').pop(); if (invitedUserServer === serverName) { this.logger.debug( From 7757d6ba4b4ee81044c68528c4779598cff1d193 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 6 Oct 2025 14:06:09 -0300 Subject: [PATCH 14/20] enforce server-to-server authentication on publicRooms endpoints --- .../src/controllers/federation/rooms.controller.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/homeserver/src/controllers/federation/rooms.controller.ts b/packages/homeserver/src/controllers/federation/rooms.controller.ts index 03ed6d06b..90080988e 100644 --- a/packages/homeserver/src/controllers/federation/rooms.controller.ts +++ b/packages/homeserver/src/controllers/federation/rooms.controller.ts @@ -1,11 +1,17 @@ -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); return app + .use(isAuthenticatedMiddleware(eventAuthService)) .get( '/_matrix/federation/v1/publicRooms', async ({ query }) => { From af92e16a10ee2c8b14a9e0b5db783cc84bb04f4b Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 7 Oct 2025 09:04:07 -0300 Subject: [PATCH 15/20] invite route checking by just auth instead of resource --- .../src/controllers/federation/invite.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/homeserver/src/controllers/federation/invite.controller.ts b/packages/homeserver/src/controllers/federation/invite.controller.ts index fe3da48f8..a43fa1971 100644 --- a/packages/homeserver/src/controllers/federation/invite.controller.ts +++ b/packages/homeserver/src/controllers/federation/invite.controller.ts @@ -3,7 +3,7 @@ import { EventAuthorizationService, InviteService, } 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 { ProcessInviteParamsDto, RoomVersionDto } from '../../dtos'; @@ -12,7 +12,7 @@ export const invitePlugin = (app: Elysia) => { const inviteService = container.resolve(InviteService); const eventAuthService = container.resolve(EventAuthorizationService); - return app.use(canAccessResourceMiddleware(eventAuthService, 'room')).put( + return app.use(isAuthenticatedMiddleware(eventAuthService)).put( '/_matrix/federation/v2/invite/:roomId/:eventId', async ({ body, params: { roomId, eventId } }) => { return inviteService.processInvite( From 489b4c3d77f24d31839f69bf854f1b1951253b61 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 7 Oct 2025 11:20:56 -0300 Subject: [PATCH 16/20] getFullRoomState -> getLatestRoomState --- .../federation-sdk/src/services/event-authorization.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/federation-sdk/src/services/event-authorization.service.ts b/packages/federation-sdk/src/services/event-authorization.service.ts index f70a9d732..d4a0e3026 100644 --- a/packages/federation-sdk/src/services/event-authorization.service.ts +++ b/packages/federation-sdk/src/services/event-authorization.service.ts @@ -255,7 +255,7 @@ export class EventAuthorizationService { roomId: string, serverName: string, ): Promise { - const state = await this.stateService.getFullRoomState(roomId); + const state = await this.stateService.getLatestRoomState(roomId); if (!state) { this.logger.debug(`Room ${roomId} not found`); return false; From af1b0cc3a76294ee8714648282e2faf83e0b167e Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 8 Oct 2025 18:38:04 -0300 Subject: [PATCH 17/20] add m.room.server_acl event support --- packages/federation-sdk/src/index.ts | 4 ++++ .../federation-sdk/src/repositories/event.repository.ts | 1 + .../federation-sdk/src/services/staging-area.service.ts | 7 +++++++ 3 files changed, 12 insertions(+) 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..16353fff7 100644 --- a/packages/federation-sdk/src/repositories/event.repository.ts +++ b/packages/federation-sdk/src/repositories/event.repository.ts @@ -70,6 +70,7 @@ export class EventRepository { break; case 'm.room.redaction': + case 'm.room.server_acl': queries = [baseQueries.create, baseQueries.powerLevels]; break; 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, From bfbdaefe7ed4e7f7898ac55c5b1f1d545a126bee Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 8 Oct 2025 13:40:30 -0300 Subject: [PATCH 18/20] pair route middlewares with rc and matrix protocol --- .../services/event-authorization.service.ts | 22 +++++++++++++++++++ .../src/services/invite.service.ts | 15 +++++++++---- .../federation/invite.controller.ts | 7 +++++- .../federation/transactions.controller.ts | 4 ++-- .../src/middlewares/isAuthenticated.ts | 6 ++--- 5 files changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/federation-sdk/src/services/event-authorization.service.ts b/packages/federation-sdk/src/services/event-authorization.service.ts index d4a0e3026..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'); @@ -251,6 +258,21 @@ export class EventAuthorizationService { return false; } + async checkAclForInvite(roomId: string, senderServer: string): Promise { + const state = await this.stateService.getLatestRoomState(roomId); + + const aclEvent = state.get('m.room.server_acl:'); + if (!aclEvent) { + return; + } + + const isAllowed = await this.checkServerAcl(aclEvent, senderServer); + if (!isAllowed) { + this.logger.warn(`Sender ${senderServer} denied by room ${roomId} ACL`); + throw new AclDeniedError(senderServer, roomId); + } + } + async serverHasAccessToResource( roomId: string, serverName: string, 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/homeserver/src/controllers/federation/invite.controller.ts b/packages/homeserver/src/controllers/federation/invite.controller.ts index a43fa1971..0f81c0ca2 100644 --- a/packages/homeserver/src/controllers/federation/invite.controller.ts +++ b/packages/homeserver/src/controllers/federation/invite.controller.ts @@ -14,12 +14,17 @@ export const invitePlugin = (app: Elysia) => { 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/transactions.controller.ts b/packages/homeserver/src/controllers/federation/transactions.controller.ts index c9989a073..3dbc10cf4 100644 --- a/packages/homeserver/src/controllers/federation/transactions.controller.ts +++ b/packages/homeserver/src/controllers/federation/transactions.controller.ts @@ -75,7 +75,7 @@ export const transactionsPlugin = (app: Elysia) => { }; }, { - use: isAuthenticatedMiddleware(eventAuthService), + use: canAccessResourceMiddleware(eventAuthService, 'event'), params: GetEventParamsDto, response: { 200: GetEventResponseDto, @@ -124,7 +124,7 @@ export const transactionsPlugin = (app: Elysia) => { } }, { - use: isAuthenticatedMiddleware(eventAuthService), + use: canAccessResourceMiddleware(eventAuthService, 'room'), params: BackfillParamsDto, query: BackfillQueryDto, response: { diff --git a/packages/homeserver/src/middlewares/isAuthenticated.ts b/packages/homeserver/src/middlewares/isAuthenticated.ts index b552c40a8..9588c0174 100644 --- a/packages/homeserver/src/middlewares/isAuthenticated.ts +++ b/packages/homeserver/src/middlewares/isAuthenticated.ts @@ -16,7 +16,7 @@ export const isAuthenticatedMiddleware = ( if (!authorizationHeader) { set.status = 401; return { - authenticatedServer: null, + authenticatedServer: undefined, }; } @@ -42,7 +42,7 @@ export const isAuthenticatedMiddleware = ( if (!isValid) { set.status = 401; return { - authenticatedServer: null, + authenticatedServer: undefined, }; } @@ -53,7 +53,7 @@ export const isAuthenticatedMiddleware = ( console.error('Authentication error:', error); set.status = 500; return { - authenticatedServer: null, + authenticatedServer: undefined, }; } }) From 3d8449a2026d52a378a90015de9ca5685e139622 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 10 Oct 2025 19:07:13 -0300 Subject: [PATCH 19/20] remove pino dep --- packages/federation-sdk/src/server-discovery/discovery.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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); From 31467d40895d1dd62d75419bc27c59d53b1babf1 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 10 Oct 2025 19:12:56 -0300 Subject: [PATCH 20/20] include membership for acl --- packages/federation-sdk/src/repositories/event.repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/federation-sdk/src/repositories/event.repository.ts b/packages/federation-sdk/src/repositories/event.repository.ts index 16353fff7..b88f18e17 100644 --- a/packages/federation-sdk/src/repositories/event.repository.ts +++ b/packages/federation-sdk/src/repositories/event.repository.ts @@ -70,7 +70,6 @@ export class EventRepository { break; case 'm.room.redaction': - case 'm.room.server_acl': queries = [baseQueries.create, baseQueries.powerLevels]; break; @@ -81,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,