From 7f407d653d687a2363f183911176d46f525bda02 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Sun, 23 Nov 2025 23:00:19 -0300 Subject: [PATCH 1/5] feat: add improved errors messaging --- packages/federation-sdk/src/index.ts | 5 + packages/federation-sdk/src/sdk.ts | 10 ++ .../src/services/config.service.ts | 22 +++ .../services/event-authorization.service.ts | 2 +- .../services/federation-validation.service.ts | 126 ++++++++++++++++++ .../src/services/invite.service.ts | 9 +- .../src/services/room.service.ts | 7 + 7 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 packages/federation-sdk/src/services/federation-validation.service.ts diff --git a/packages/federation-sdk/src/index.ts b/packages/federation-sdk/src/index.ts index 1470efebc..427e1d067 100644 --- a/packages/federation-sdk/src/index.ts +++ b/packages/federation-sdk/src/index.ts @@ -63,6 +63,10 @@ export { } from './utils/event-schemas'; export { errCodes } from './utils/response-codes'; export { NotAllowedError } from './services/invite.service'; +export { + FederationValidationService, + FederationValidationError, +} from './services/federation-validation.service'; export type HomeserverEventSignatures = { 'homeserver.ping': { @@ -136,6 +140,7 @@ export { roomIdSchema, userIdSchema, eventIdSchema, + extractDomainFromId, } from '@rocket.chat/federation-room'; export async function init({ diff --git a/packages/federation-sdk/src/sdk.ts b/packages/federation-sdk/src/sdk.ts index 0ef44dc43..6b707e74a 100644 --- a/packages/federation-sdk/src/sdk.ts +++ b/packages/federation-sdk/src/sdk.ts @@ -8,6 +8,7 @@ import { EventAuthorizationService } from './services/event-authorization.servic import { EventEmitterService } from './services/event-emitter.service'; import { EventService } from './services/event.service'; import { FederationRequestService } from './services/federation-request.service'; +import { FederationValidationService } from './services/federation-validation.service'; import { FederationService } from './services/federation.service'; import { InviteService } from './services/invite.service'; import { MediaService } from './services/media.service'; @@ -39,6 +40,7 @@ export class FederationSDK { private readonly federationRequestService: FederationRequestService, private readonly federationService: FederationService, public readonly eventEmitterService: EventEmitterService, + private readonly federationValidationService: FederationValidationService, ) {} createDirectMessageRoom( @@ -224,6 +226,14 @@ export class FederationSDK { return this.wellKnownService.getWellKnownHostData(...args); } + validateOutboundUser( + ...args: Parameters< + typeof this.federationValidationService.validateOutboundUser + > + ) { + return this.federationValidationService.validateOutboundUser(...args); + } + updateUserPowerLevel( ...args: Parameters ) { diff --git a/packages/federation-sdk/src/services/config.service.ts b/packages/federation-sdk/src/services/config.service.ts index ac4e44727..38c31ea39 100644 --- a/packages/federation-sdk/src/services/config.service.ts +++ b/packages/federation-sdk/src/services/config.service.ts @@ -35,6 +35,12 @@ export interface AppConfig { processTyping: boolean; processPresence: boolean; }; + federation: { + validation?: { + networkCheckTimeoutMs?: number; + userCheckTimeoutMs?: number; + }; + }; } export const AppConfigSchema = z.object({ @@ -76,6 +82,22 @@ export const AppConfigSchema = z.object({ processTyping: z.boolean(), processPresence: z.boolean(), }), + federation: z.object({ + validation: z + .object({ + networkCheckTimeoutMs: z + .number() + .int() + .min(1000, 'Network check timeout must be at least 1000ms') + .default(5000), + userCheckTimeoutMs: z + .number() + .int() + .min(1000, 'User check timeout must be at least 1000ms') + .default(10000), + }) + .optional(), + }), }); @singleton() diff --git a/packages/federation-sdk/src/services/event-authorization.service.ts b/packages/federation-sdk/src/services/event-authorization.service.ts index d676a7673..e73f3081f 100644 --- a/packages/federation-sdk/src/services/event-authorization.service.ts +++ b/packages/federation-sdk/src/services/event-authorization.service.ts @@ -211,7 +211,7 @@ export class EventAuthorizationService { } // as per Matrix spec: https://spec.matrix.org/v1.15/client-server-api/#mroomserver_acl - private async checkServerAcl( + async checkServerAcl( aclEvent: PersistentEventBase | undefined, serverName: string, ): Promise { diff --git a/packages/federation-sdk/src/services/federation-validation.service.ts b/packages/federation-sdk/src/services/federation-validation.service.ts new file mode 100644 index 000000000..82f1ef73b --- /dev/null +++ b/packages/federation-sdk/src/services/federation-validation.service.ts @@ -0,0 +1,126 @@ +import type { RoomID, UserID } from '@rocket.chat/federation-room'; +import { extractDomainFromId } from '@rocket.chat/federation-room'; +import { singleton } from 'tsyringe'; +import { FederationEndpoints } from '../specs/federation-api'; +import { ConfigService } from './config.service'; +import { EventAuthorizationService } from './event-authorization.service'; +import { FederationRequestService } from './federation-request.service'; +import { StateService } from './state.service'; + +export class FederationValidationError extends Error { + public error: string; + + constructor( + public code: 'POLICY_DENIED' | 'CONNECTION_FAILED' | 'USER_NOT_FOUND', + public userMessage: string, + public httpStatus: 403 | 502 | 404, + ) { + super(userMessage); + this.name = 'FederationValidationError'; + this.error = `federation-${code.toLowerCase().replace(/_/g, '-')}`; + } +} + +@singleton() +export class FederationValidationService { + constructor( + private readonly configService: ConfigService, + private readonly federationRequestService: FederationRequestService, + private readonly stateService: StateService, + private readonly eventAuthorizationService: EventAuthorizationService, + ) {} + + async validateOutboundUser(userId: UserID): Promise { + const domain = extractDomainFromId(userId); + await this.checkDomainReachable(domain); + await this.checkUserExists(userId, domain); + } + + async validateOutboundInvite(userId: UserID, roomId: RoomID): Promise { + const domain = extractDomainFromId(userId); + await this.checkRoomAcl(roomId, domain); + await this.checkDomainReachable(domain); + await this.checkUserExists(userId, domain); + } + + private async checkRoomAcl(roomId: RoomID, domain: string): Promise { + try { + const state = await this.stateService.getLatestRoomState(roomId); + const aclEvent = state.get('m.room.server_acl:'); + if (!aclEvent || !aclEvent.isServerAclEvent()) { + return; + } + + const isAllowed = await this.eventAuthorizationService.checkServerAcl( + aclEvent, + domain, + ); + if (!isAllowed) { + throw new FederationValidationError( + 'POLICY_DENIED', + "Action Blocked. The room's access control policy blocks communication with this domain.", + 403, + ); + } + } catch (error) { + if (error instanceof FederationValidationError) { + throw error; + } + } + } + + private async checkDomainReachable(domain: string): Promise { + const config = this.configService.getConfig('federation'); + const timeoutMs = config.validation?.networkCheckTimeoutMs || 5000; + + try { + const versionPromise = this.federationRequestService.get<{ + server: { name?: string; version?: string }; + }>(domain, FederationEndpoints.version); + + await this.withTimeout(versionPromise, timeoutMs); + } catch (_error) { + throw new FederationValidationError( + 'CONNECTION_FAILED', + 'Connection Failed. The server domain could not be reached or does not support federation.', + 502, + ); + } + } + + private async checkUserExists(userId: UserID, domain: string): Promise { + const config = this.configService.getConfig('federation'); + const timeoutMs = config.validation?.userCheckTimeoutMs || 10000; + + try { + const uri = FederationEndpoints.queryProfile(userId); + const queryParams = { user_id: userId }; + + const profilePromise = this.federationRequestService.get<{ + displayname?: string; + avatar_url?: string; + }>(domain, uri, queryParams); + + await this.withTimeout(profilePromise, timeoutMs); + } catch (_error) { + throw new FederationValidationError( + 'USER_NOT_FOUND', + 'Invitation blocked. The specified user couldn’t be found on their homeserver.', + 502, + ); + } + } + + private async withTimeout( + promise: Promise, + timeoutMs: number, + ): Promise { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Operation timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + + return Promise.race([promise, timeoutPromise]); + } +} diff --git a/packages/federation-sdk/src/services/invite.service.ts b/packages/federation-sdk/src/services/invite.service.ts index 8abd3e8af..1e5083093 100644 --- a/packages/federation-sdk/src/services/invite.service.ts +++ b/packages/federation-sdk/src/services/invite.service.ts @@ -14,6 +14,7 @@ import { EventRepository } from '../repositories/event.repository'; import { ConfigService } from './config.service'; import { EventAuthorizationService } from './event-authorization.service'; import { EventEmitterService } from './event-emitter.service'; +import { FederationValidationService } from './federation-validation.service'; import { FederationService } from './federation.service'; import { StateService } from './state.service'; export class NotAllowedError extends Error { @@ -35,6 +36,7 @@ export class InviteService { private readonly emitterService: EventEmitterService, @inject(delay(() => EventRepository)) private readonly eventRepository: EventRepository, + private readonly federationValidationService: FederationValidationService, ) {} /** @@ -93,6 +95,11 @@ export class InviteService { ); } + await this.federationValidationService.validateOutboundInvite( + userId, + roomId, + ); + // if user invited belongs to our server if (invitedServer === this.configService.serverName) { await stateService.handlePdu(inviteEvent); @@ -111,9 +118,7 @@ export class InviteService { }; } - // invited user from another room // get signed invite event - const inviteResponse = await federationService.inviteUser( inviteEvent, roomVersion, diff --git a/packages/federation-sdk/src/services/room.service.ts b/packages/federation-sdk/src/services/room.service.ts index e4ab7d7c4..a34d1212e 100644 --- a/packages/federation-sdk/src/services/room.service.ts +++ b/packages/federation-sdk/src/services/room.service.ts @@ -37,6 +37,7 @@ import { EventAuthorizationService } from './event-authorization.service'; import { EventEmitterService } from './event-emitter.service'; import { EventFetcherService } from './event-fetcher.service'; import { EventService } from './event.service'; +import { FederationValidationService } from './federation-validation.service'; import { FederationService } from './federation.service'; import { InviteService } from './invite.service'; import { @@ -63,6 +64,7 @@ export class RoomService { @inject(delay(() => EventStagingRepository)) private readonly eventStagingRepository: EventStagingRepository, private readonly emitterService: EventEmitterService, + private readonly federationValidationService: FederationValidationService, ) {} private validatePowerLevelChange( @@ -1640,6 +1642,11 @@ export class RoomService { await stateService.handlePdu(guestAccessEvent); if (isExternalUser) { + await this.federationValidationService.validateOutboundInvite( + targetUserId, + roomCreateEvent.roomId, + ); + await this.inviteService.inviteUserToRoom( targetUserId, roomCreateEvent.roomId, From 5cf78a42ec872320f57fc35d86ed2323a8329be4 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 25 Nov 2025 19:40:19 -0300 Subject: [PATCH 2/5] chore: remove httpCode from FederationValidationError --- .../src/services/federation-validation.service.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/federation-sdk/src/services/federation-validation.service.ts b/packages/federation-sdk/src/services/federation-validation.service.ts index 82f1ef73b..4e89af85b 100644 --- a/packages/federation-sdk/src/services/federation-validation.service.ts +++ b/packages/federation-sdk/src/services/federation-validation.service.ts @@ -13,7 +13,6 @@ export class FederationValidationError extends Error { constructor( public code: 'POLICY_DENIED' | 'CONNECTION_FAILED' | 'USER_NOT_FOUND', public userMessage: string, - public httpStatus: 403 | 502 | 404, ) { super(userMessage); this.name = 'FederationValidationError'; @@ -59,7 +58,6 @@ export class FederationValidationService { throw new FederationValidationError( 'POLICY_DENIED', "Action Blocked. The room's access control policy blocks communication with this domain.", - 403, ); } } catch (error) { @@ -83,7 +81,6 @@ export class FederationValidationService { throw new FederationValidationError( 'CONNECTION_FAILED', 'Connection Failed. The server domain could not be reached or does not support federation.', - 502, ); } } @@ -105,8 +102,7 @@ export class FederationValidationService { } catch (_error) { throw new FederationValidationError( 'USER_NOT_FOUND', - 'Invitation blocked. The specified user couldn’t be found on their homeserver.', - 502, + "Invitation blocked. The specified user couldn't be found on their homeserver.", ); } } From 7e526907eb26976d5a4fd4921a6dc30094c1aa3f Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 25 Nov 2025 19:51:35 -0300 Subject: [PATCH 3/5] chore: remove try catch block from checkRoomAcl --- .../services/federation-validation.service.ts | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/packages/federation-sdk/src/services/federation-validation.service.ts b/packages/federation-sdk/src/services/federation-validation.service.ts index 4e89af85b..566374bee 100644 --- a/packages/federation-sdk/src/services/federation-validation.service.ts +++ b/packages/federation-sdk/src/services/federation-validation.service.ts @@ -43,27 +43,21 @@ export class FederationValidationService { } private async checkRoomAcl(roomId: RoomID, domain: string): Promise { - try { - const state = await this.stateService.getLatestRoomState(roomId); - const aclEvent = state.get('m.room.server_acl:'); - if (!aclEvent || !aclEvent.isServerAclEvent()) { - return; - } - - const isAllowed = await this.eventAuthorizationService.checkServerAcl( - aclEvent, - domain, + const state = await this.stateService.getLatestRoomState(roomId); + const aclEvent = state.get('m.room.server_acl:'); + if (!aclEvent || !aclEvent.isServerAclEvent()) { + return; + } + + const isAllowed = await this.eventAuthorizationService.checkServerAcl( + aclEvent, + domain, + ); + if (!isAllowed) { + throw new FederationValidationError( + 'POLICY_DENIED', + "Action Blocked. The room's access control policy blocks communication with this domain.", ); - if (!isAllowed) { - throw new FederationValidationError( - 'POLICY_DENIED', - "Action Blocked. The room's access control policy blocks communication with this domain.", - ); - } - } catch (error) { - if (error instanceof FederationValidationError) { - throw error; - } } } From 0c34985f81477483c42ef80fa0d2b9cf151b2b19 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 19 Dec 2025 19:38:25 -0300 Subject: [PATCH 4/5] disable validation during tests --- .../federation-sdk/src/services/invite.service.ts | 1 + .../src/services/room.service.spec.ts | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/federation-sdk/src/services/invite.service.ts b/packages/federation-sdk/src/services/invite.service.ts index 1e5083093..2ecf90302 100644 --- a/packages/federation-sdk/src/services/invite.service.ts +++ b/packages/federation-sdk/src/services/invite.service.ts @@ -36,6 +36,7 @@ export class InviteService { private readonly emitterService: EventEmitterService, @inject(delay(() => EventRepository)) private readonly eventRepository: EventRepository, + @inject(delay(() => FederationValidationService)) // need to delay to be able to inject during tests private readonly federationValidationService: FederationValidationService, ) {} diff --git a/packages/federation-sdk/src/services/room.service.spec.ts b/packages/federation-sdk/src/services/room.service.spec.ts index 3d10bc1c4..207fcc47c 100644 --- a/packages/federation-sdk/src/services/room.service.spec.ts +++ b/packages/federation-sdk/src/services/room.service.spec.ts @@ -8,7 +8,7 @@ import { StateMapKey, } from '@rocket.chat/federation-room'; import { container } from 'tsyringe'; -import { federationSDK, init } from '..'; +import { FederationValidationService, federationSDK, init } from '..'; import { AppConfig, ConfigService } from './config.service'; import { RoomService } from './room.service'; import { StateService } from './state.service'; @@ -29,7 +29,6 @@ describe('RoomService', async () => { }; init({ - emitter: undefined, dbConfig: databaseConfig, }); }); @@ -44,6 +43,18 @@ describe('RoomService', async () => { useValue: configService, }); + // dont validate anything during tests + container.register(FederationValidationService, { + useValue: { + async validateOutboundUser() { + return true; + }, + async validateOutboundInvite() { + return true; + }, + } as unknown as FederationValidationService, + }); + const stateService = container.resolve(StateService); const roomService = container.resolve(RoomService); From 326cb5e209d240e07edd257dff626202209ee036 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 19 Dec 2025 19:53:21 -0300 Subject: [PATCH 5/5] simplify config --- .../src/services/config.service.ts | 36 ++++++++----------- .../services/federation-validation.service.ts | 8 ++--- 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/packages/federation-sdk/src/services/config.service.ts b/packages/federation-sdk/src/services/config.service.ts index 38c31ea39..398813287 100644 --- a/packages/federation-sdk/src/services/config.service.ts +++ b/packages/federation-sdk/src/services/config.service.ts @@ -35,12 +35,8 @@ export interface AppConfig { processTyping: boolean; processPresence: boolean; }; - federation: { - validation?: { - networkCheckTimeoutMs?: number; - userCheckTimeoutMs?: number; - }; - }; + userCheckTimeoutMs?: number; + networkCheckTimeoutMs?: number; } export const AppConfigSchema = z.object({ @@ -82,22 +78,18 @@ export const AppConfigSchema = z.object({ processTyping: z.boolean(), processPresence: z.boolean(), }), - federation: z.object({ - validation: z - .object({ - networkCheckTimeoutMs: z - .number() - .int() - .min(1000, 'Network check timeout must be at least 1000ms') - .default(5000), - userCheckTimeoutMs: z - .number() - .int() - .min(1000, 'User check timeout must be at least 1000ms') - .default(10000), - }) - .optional(), - }), + networkCheckTimeoutMs: z + .number() + .int() + .min(1000, 'Network check timeout must be at least 1000ms') + .default(5000) + .optional(), + userCheckTimeoutMs: z + .number() + .int() + .min(1000, 'User check timeout must be at least 1000ms') + .default(10000) + .optional(), }); @singleton() diff --git a/packages/federation-sdk/src/services/federation-validation.service.ts b/packages/federation-sdk/src/services/federation-validation.service.ts index 566374bee..c7f152333 100644 --- a/packages/federation-sdk/src/services/federation-validation.service.ts +++ b/packages/federation-sdk/src/services/federation-validation.service.ts @@ -62,8 +62,8 @@ export class FederationValidationService { } private async checkDomainReachable(domain: string): Promise { - const config = this.configService.getConfig('federation'); - const timeoutMs = config.validation?.networkCheckTimeoutMs || 5000; + const timeoutMs = + this.configService.getConfig('networkCheckTimeoutMs') || 5000; try { const versionPromise = this.federationRequestService.get<{ @@ -80,8 +80,8 @@ export class FederationValidationService { } private async checkUserExists(userId: UserID, domain: string): Promise { - const config = this.configService.getConfig('federation'); - const timeoutMs = config.validation?.userCheckTimeoutMs || 10000; + const timeoutMs = + this.configService.getConfig('userCheckTimeoutMs') || 10000; try { const uri = FederationEndpoints.queryProfile(userId);