diff --git a/packages/federation-sdk/src/index.ts b/packages/federation-sdk/src/index.ts index 1470efeb..427e1d06 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 0ef44dc4..6b707e74 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 ac4e4472..39881328 100644 --- a/packages/federation-sdk/src/services/config.service.ts +++ b/packages/federation-sdk/src/services/config.service.ts @@ -35,6 +35,8 @@ export interface AppConfig { processTyping: boolean; processPresence: boolean; }; + userCheckTimeoutMs?: number; + networkCheckTimeoutMs?: number; } export const AppConfigSchema = z.object({ @@ -76,6 +78,18 @@ export const AppConfigSchema = z.object({ processTyping: z.boolean(), processPresence: z.boolean(), }), + 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/event-authorization.service.ts b/packages/federation-sdk/src/services/event-authorization.service.ts index d676a767..e73f3081 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 00000000..c7f15233 --- /dev/null +++ b/packages/federation-sdk/src/services/federation-validation.service.ts @@ -0,0 +1,116 @@ +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, + ) { + 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 { + 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.", + ); + } + } + + private async checkDomainReachable(domain: string): Promise { + const timeoutMs = + this.configService.getConfig('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.', + ); + } + } + + private async checkUserExists(userId: UserID, domain: string): Promise { + const timeoutMs = + this.configService.getConfig('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.", + ); + } + } + + 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 8abd3e8a..2ecf9030 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,8 @@ 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, ) {} /** @@ -93,6 +96,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 +119,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.spec.ts b/packages/federation-sdk/src/services/room.service.spec.ts index 3d10bc1c..207fcc47 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); diff --git a/packages/federation-sdk/src/services/room.service.ts b/packages/federation-sdk/src/services/room.service.ts index e4ab7d7c..a34d1212 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,