diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index bb99ae7237f19..7284c799747f5 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -3,6 +3,7 @@ import { AppsEngineException } from '@rocket.chat/apps-engine/definition/excepti import { FederationMatrix, Message, Room, Team } from '@rocket.chat/core-services'; import type { ICreateRoomParams, ISubscriptionExtraData } from '@rocket.chat/core-services'; import type { ICreatedRoom, IUser, IRoom, RoomType } from '@rocket.chat/core-typings'; +import { isFederationDomainAllowedForUsernames, FederationValidationError } from '@rocket.chat/federation-matrix'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -190,6 +191,32 @@ export const createRoom = async ( }); } + if (shouldBeHandledByFederation && onlyUsernames(members)) { + // check RC allowlist for domain + const isAllowed = await isFederationDomainAllowedForUsernames(members); + if (!isAllowed) { + throw new Meteor.Error( + 'federation-policy-denied', + "Action Blocked. Communication with one of the domains in the list is restricted by your organization's security policy.", + { method: 'createRoom' }, + ); + } + + // validate external users (network + user existence checks) + try { + // TODO: Use common function to extract and validate federated users + const federatedUsers = members + .filter((member: string | IUser) => (typeof member === 'string' ? member.includes(':') : member.username?.includes(':'))) + .map((member: string | IUser) => (typeof member === 'string' ? member : member.username!)); + await FederationMatrix.validateFederatedUsers(federatedUsers); + } catch (error: FederationValidationError | unknown) { + if (error instanceof FederationValidationError) { + throw new Meteor.Error(error.error, error.userMessage, { method: 'createRoom' }); + } + throw error; + } + } + if (type === 'd') { return createDirectRoom(members as IUser[], extraData, { ...options, creator: options?.creator || owner?._id }); } diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index fa2433a9aa424..c0a3d06a9e86e 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -1,7 +1,11 @@ import { FederationMatrix, Authorization, MeteorError, Room } from '@rocket.chat/core-services'; import { isEditedMessage, isRoomNativeFederated, isUserNativeFederated } from '@rocket.chat/core-typings'; import type { IRoomNativeFederated, IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; -import { validateFederatedUsername } from '@rocket.chat/federation-matrix'; +import { + validateFederatedUsername, + FederationValidationError, + isFederationDomainAllowedForUsernames, +} from '@rocket.chat/federation-matrix'; import { Rooms } from '@rocket.chat/models'; import { callbacks } from '../../../../server/lib/callbacks'; @@ -112,11 +116,18 @@ beforeAddUserToRoom.add( return; } - // TODO should we really check for "user" here? it is potentially an external user if (!(await Authorization.hasPermission(user._id, 'access-federation'))) { throw new MeteorError('error-not-authorized-federation', 'Not authorized to access federation'); } + const isAllowed = await isFederationDomainAllowedForUsernames([user.username]); + if (!isAllowed) { + throw new MeteorError( + 'federation-policy-denied', + "Action Blocked. Communication with one of the domains in the list is restricted by your organization's security policy.", + ); + } + // If inviter is federated, the invite came from an external transaction. // Don't propagate back to Matrix (it was already processed at origin server). if (isUserNativeFederated(inviter)) { @@ -240,6 +251,24 @@ callbacks.add( 'beforeCreateDirectRoom', async (members, room): Promise => { if (FederationActions.shouldPerformFederationAction(room)) { + const isAllowed = await isFederationDomainAllowedForUsernames(members); + if (!isAllowed) { + throw new Meteor.Error( + 'federation-policy-denied', + "Action Blocked. Communication with one of the domains in the list is restricted by your organization's security policy.", + ); + } + + try { + const federatedUsers = members.filter((username) => username.includes(':')); + await FederationMatrix.validateFederatedUsers(federatedUsers); + } catch (error: FederationValidationError | unknown) { + if (error instanceof FederationValidationError) { + throw new Meteor.Error(error.error, error.userMessage); + } + throw error; + } + await FederationMatrix.ensureFederatedUsersExistLocally(members); } }, diff --git a/ee/packages/federation-matrix/package.json b/ee/packages/federation-matrix/package.json index 72c841daf0654..e6df0b1dbd255 100644 --- a/ee/packages/federation-matrix/package.json +++ b/ee/packages/federation-matrix/package.json @@ -22,7 +22,7 @@ "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "^0.31.25", - "@rocket.chat/federation-sdk": "0.3.5", + "@rocket.chat/federation-sdk": "0.3.6", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/license": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index bb47f5b2d9fab..db07284d760c8 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -14,6 +14,8 @@ import { Logger } from '@rocket.chat/logger'; import { Users, Subscriptions, Messages, Rooms, Settings } from '@rocket.chat/models'; import emojione from 'emojione'; +import { isFederationDomainAllowed } from './api/middlewares/isFederationDomainAllowed'; +import { FederationValidationError } from './errors/FederationValidationError'; import { toExternalMessageFormat, toExternalQuoteMessageFormat } from './helpers/message.parsers'; import { MatrixMediaService } from './services/MatrixMediaService'; @@ -206,6 +208,25 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS this.processEDUPresence = (await Settings.getValueById('Federation_Service_EDU_Process_Presence')) || false; } + async validateFederatedUsers(usernames: string[]): Promise { + const hasInvalidFederatedUsername = usernames.some((username) => !validateFederatedUsername(username)); + if (hasInvalidFederatedUsername) { + throw new FederationValidationError( + 'POLICY_DENIED', + `Invalid federated username format: ${usernames.filter((username) => !validateFederatedUsername(username)).join(', ')}. Federated usernames must follow the format @username:domain.com`, + ); + } + + const federatedUsers = usernames.filter(validateFederatedUsername); + if (federatedUsers.length === 0) { + return; + } + + for await (const username of federatedUsers) { + await federationSDK.validateOutboundUser(userIdSchema.parse(username)); + } + } + async createRoom(room: IRoom, owner: IUser): Promise<{ room_id: string; event_id: string }> { if (room.t !== 'c' && room.t !== 'p') { throw new Error('Room is not a public or private room'); @@ -215,11 +236,8 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS const matrixUserId = userIdSchema.parse(`@${owner.username}:${this.serverName}`); const roomName = room.name || room.fname || 'Untitled Room'; - // canonical alias computed from name const matrixRoomResult = await federationSDK.createRoom(matrixUserId, roomName, room.t === 'c' ? 'public' : 'invite'); - this.logger.debug('Matrix room created:', matrixRoomResult); - await Rooms.setAsFederated(room._id, { mrid: matrixRoomResult.room_id, origin: this.serverName }); // Members are NOT invited here - invites are sent via beforeAddUserToRoom callback. @@ -863,24 +881,30 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS if (!homeserverUrl) { return [matrixId, 'UNABLE_TO_VERIFY']; } + try { - const result = await federationSDK.queryProfileRemote< - | { - avatar_url: string; - displayname: string; - } - | { - errcode: string; - error: string; - } - >({ homeserverUrl, userId: matrixId }); - - if ('errcode' in result && result.errcode === 'M_NOT_FOUND') { - return [matrixId, 'UNVERIFIED']; + // check RC domain allowlist + if (!(await isFederationDomainAllowed([homeserverUrl]))) { + return [matrixId, 'POLICY_DENIED']; } + // validate using homeserver (network + user existence) + await federationSDK.validateOutboundUser(userIdSchema.parse(matrixId)); + return [matrixId, 'VERIFIED']; } catch (e) { + if (e && typeof e === 'object' && 'code' in e) { + const error = e as { code: string }; + if (error.code === 'CONNECTION_FAILED') { + return [matrixId, 'UNABLE_TO_VERIFY']; + } + if (error.code === 'USER_NOT_FOUND') { + return [matrixId, 'UNVERIFIED']; + } + if (error.code === 'POLICY_DENIED') { + return [matrixId, 'POLICY_DENIED']; + } + } return [matrixId, 'UNABLE_TO_VERIFY']; } }), diff --git a/ee/packages/federation-matrix/src/api/middlewares/isFederationDomainAllowed.ts b/ee/packages/federation-matrix/src/api/middlewares/isFederationDomainAllowed.ts index 78c145b695e84..7d65a29868c80 100644 --- a/ee/packages/federation-matrix/src/api/middlewares/isFederationDomainAllowed.ts +++ b/ee/packages/federation-matrix/src/api/middlewares/isFederationDomainAllowed.ts @@ -2,6 +2,8 @@ import { Settings } from '@rocket.chat/core-services'; import { createMiddleware } from 'hono/factory'; import mem from 'mem'; +import { extractDomainFromMatrixUserId } from '../../FederationMatrix'; + // cache for 60 seconds const getAllowList = mem( async () => { @@ -16,6 +18,38 @@ const getAllowList = mem( { maxAge: 60000 }, ); +export async function isFederationDomainAllowed(domains: string[]): Promise { + const allowList = await getAllowList(); + if (!allowList || allowList.length === 0) { + return true; + } + + const isDomainAllowed = (domain: string) => { + return allowList.some((pattern) => { + if (pattern.startsWith('*.')) { + const baseDomain = pattern.slice(2); // remove '*.' + return domain.endsWith(`.${baseDomain}`); + } + + return domain === pattern || domain.endsWith(`.${pattern}`); + }); + }; + + return domains.every((domain) => isDomainAllowed(domain.toLowerCase())); +} + +export async function isFederationDomainAllowedForUsernames(usernames: string[]): Promise { + // filter out local users (those without ':' in username) and extract domains from external users + const domains = usernames.filter((username) => username.includes(':')).map((username) => extractDomainFromMatrixUserId(username)); + + // if no federated users, allow (all local users) + if (domains.length === 0) { + return true; + } + + return isFederationDomainAllowed(domains); +} + /** * Parses all key-value pairs from a Matrix authorization header. * Example: X-Matrix origin="matrix.org", key="value", ... @@ -52,8 +86,7 @@ export const isFederationDomainAllowedMiddleware = createMiddleware(async (c, ne return c.json({ errcode: 'M_MISSING_ORIGIN', error: 'Missing origin in authorization header.' }, 401); } - // Check if domain is in allowed list - if (allowList.some((allowed) => domain.endsWith(allowed))) { + if (await isFederationDomainAllowed([domain])) { return next(); } diff --git a/ee/packages/federation-matrix/src/errors/FederationValidationError.ts b/ee/packages/federation-matrix/src/errors/FederationValidationError.ts new file mode 100644 index 0000000000000..44f046a048fff --- /dev/null +++ b/ee/packages/federation-matrix/src/errors/FederationValidationError.ts @@ -0,0 +1,13 @@ +// Local copy to avoid broken import chain in homeserver's federation-sdk +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, '-')}`; + } +} diff --git a/ee/packages/federation-matrix/src/index.ts b/ee/packages/federation-matrix/src/index.ts index b56729b4524ba..b3c7c0e951169 100644 --- a/ee/packages/federation-matrix/src/index.ts +++ b/ee/packages/federation-matrix/src/index.ts @@ -4,6 +4,10 @@ export { FederationMatrix, validateFederatedUsername } from './FederationMatrix' export { generateEd25519RandomSecretKey } from '@rocket.chat/federation-sdk'; +export { FederationValidationError } from './errors/FederationValidationError'; + export { getFederationRoutes } from './api/routes'; export { setupFederationMatrix, configureFederationMatrixSettings } from './setup'; + +export { isFederationDomainAllowed, isFederationDomainAllowedForUsernames } from './api/middlewares/isFederationDomainAllowed'; diff --git a/ee/packages/federation-matrix/src/setup.ts b/ee/packages/federation-matrix/src/setup.ts index f307d9c1e5327..20f6e415fc555 100644 --- a/ee/packages/federation-matrix/src/setup.ts +++ b/ee/packages/federation-matrix/src/setup.ts @@ -95,6 +95,12 @@ export function configureFederationMatrixSettings(settings: { processTyping: processEDUTyping, processPresence: processEDUPresence, }, + federation: { + validation: { + networkCheckTimeoutMs: Number.parseInt(process.env.FEDERATION_NETWORK_CHECK_TIMEOUT_MS || '3000', 10), + userCheckTimeoutMs: Number.parseInt(process.env.FEDERATION_USER_CHECK_TIMEOUT_MS || '3000', 10), + }, + }, }); } diff --git a/packages/core-services/package.json b/packages/core-services/package.json index 6da52c26e7aa7..0812c9ee71f01 100644 --- a/packages/core-services/package.json +++ b/packages/core-services/package.json @@ -18,7 +18,7 @@ }, "dependencies": { "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/federation-sdk": "0.3.5", + "@rocket.chat/federation-sdk": "0.3.6", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/icons": "~0.46.0", "@rocket.chat/media-signaling": "workspace:^", diff --git a/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index ee721b18d5c61..ec566b8a6415c 100644 --- a/packages/core-services/src/types/IFederationMatrixService.ts +++ b/packages/core-services/src/types/IFederationMatrixService.ts @@ -29,4 +29,5 @@ export interface IFederationMatrixService { notifyUserTyping(rid: string, user: string, isTyping: boolean): Promise; verifyMatrixIds(matrixIds: string[]): Promise<{ [key: string]: string }>; handleInvite(subscriptionId: ISubscription['_id'], userId: IUser['_id'], action: 'accept' | 'reject'): Promise; + validateFederatedUsers(usernames: string[]): Promise; } diff --git a/yarn.lock b/yarn.lock index 87ef47de32823..fe33e2be83f18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8464,7 +8464,7 @@ __metadata: "@rocket.chat/apps-engine": "workspace:^" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.3.5" + "@rocket.chat/federation-sdk": "npm:0.3.6" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/icons": "npm:~0.46.0" "@rocket.chat/jest-presets": "workspace:~" @@ -8670,7 +8670,7 @@ __metadata: "@rocket.chat/ddp-client": "workspace:^" "@rocket.chat/emitter": "npm:^0.31.25" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.3.5" + "@rocket.chat/federation-sdk": "npm:0.3.6" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/license": "workspace:^" "@rocket.chat/models": "workspace:^" @@ -8715,6 +8715,25 @@ __metadata: languageName: node linkType: hard +"@rocket.chat/federation-sdk@npm:0.3.6": + version: 0.3.6 + resolution: "@rocket.chat/federation-sdk@npm:0.3.6" + dependencies: + "@datastructures-js/priority-queue": "npm:^6.3.5" + "@noble/ed25519": "npm:^3.0.0" + "@rocket.chat/emitter": "npm:^0.31.25" + mongodb: "npm:^6.16.0" + pino: "npm:^8.21.0" + reflect-metadata: "npm:^0.2.2" + tsyringe: "npm:^4.10.0" + tweetnacl: "npm:^1.0.3" + zod: "npm:^3.24.1" + peerDependencies: + typescript: ~5.9.2 + checksum: 10/0fb80c2f62ec8ac53b433571672bf79cab1050c54e5733b463f7592e3fdc059e21535fa5b1c44b0660d9ac78325b66f0cada4f1511ce0d420bb07bf963d8aaad + languageName: node + linkType: hard + "@rocket.chat/fuselage-forms@npm:~0.1.1": version: 0.1.1 resolution: "@rocket.chat/fuselage-forms@npm:0.1.1"