Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/meteor/app/lib/server/functions/createRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -190,6 +191,32 @@ export const createRoom = async <T extends RoomType>(
});
}

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!));
Comment on lines +208 to +210
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Inconsistent federated user detection logic and unnecessary type annotations.

Two issues:

  1. Inconsistent detection criteria: Line 164 checks for both : AND @ to identify federated users, but line 209 only checks for :. This inconsistency could cause members to be incorrectly classified. Matrix user IDs follow the format @localpart:domain, so both characters should be required.

  2. Unnecessary complexity: Since this code is inside the onlyUsernames(members) type guard, members is guaranteed to be string[]. The type annotations (string | IUser) and the runtime type checks (typeof member === 'string') are unnecessary.

🔎 Proposed fix
-		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!));
+		const federatedUsers = members.filter((member) => member.includes(':') && member.includes('@'));
🤖 Prompt for AI Agents
In apps/meteor/app/lib/server/functions/createRoom.ts around lines 208 to 210,
the federated-user detection is inconsistent and overly complex: change the
filter to require both '@' and ':' (e.g., member.includes('@') &&
member.includes(':')) to match Matrix IDs, and remove the unnecessary union
types and runtime type checks — treat members as string[] (since
onlyUsernames(members) guarantees that) and map directly from member to member.

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 });
}
Expand Down
33 changes: 31 additions & 2 deletions apps/meteor/ee/server/hooks/federation/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -240,6 +251,24 @@ callbacks.add(
'beforeCreateDirectRoom',
async (members, room): Promise<void> => {
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);
}
},
Expand Down
2 changes: 1 addition & 1 deletion ee/packages/federation-matrix/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand Down
56 changes: 40 additions & 16 deletions ee/packages/federation-matrix/src/FederationMatrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -206,6 +208,25 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS
this.processEDUPresence = (await Settings.getValueById<boolean>('Federation_Service_EDU_Process_Presence')) || false;
}

async validateFederatedUsers(usernames: string[]): Promise<void> {
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));
}
}
Comment on lines +211 to +228
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Logic inconsistency: method rejects local usernames but includes filtering for them.

The method checks if any username is invalid federated format (line 212) and throws before reaching the filter (line 220). This creates confusion:

  1. Lines 212-218 reject the entire call if ANY username isn't federated format (including local usernames like "localuser")
  2. Line 220 filters to federated usernames - but this is unreachable if any local usernames exist
  3. The filter suggests intent to handle mixed inputs, but the validation prevents it

The past review comment flagged this issue as addressed in commit 82fa056, but the logic still rejects mixed inputs. For example, ["localuser", "@fed:remote.com"] will throw POLICY_DENIED with a confusing message stating "localuser" is an invalid federated format.

Recommended fix: Pre-filter to federated usernames at the start, matching the behavior of isFederationDomainAllowedForUsernames:

🔎 Proposed refactor to handle mixed inputs
 async validateFederatedUsers(usernames: string[]): Promise<void> {
-	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));
 	}
 }

Alternatively, if the method should strictly validate that all inputs are federated format, update the method name (e.g., validateAllUsersFederated) and remove the filter on line 220.

🤖 Prompt for AI Agents
In ee/packages/federation-matrix/src/FederationMatrix.ts around lines 211 to
228, the method currently throws if ANY username is not a federated format, but
then also filters federated usernames — causing mixed inputs (e.g.,
["localuser", "@fed:remote.com"]) to be rejected instead of processing only
federated ones. Change the logic to pre-filter federated usernames first (const
federatedUsers = usernames.filter(validateFederatedUsername)); if
federatedUsers.length === 0 return; then iterate and validate only those
federatedUsers with
federationSDK.validateOutboundUser(userIdSchema.parse(username)); remove the
initial throw for invalid formats; alternatively, if strict-all-federated
behavior is intended, rename the method to indicate that (e.g.,
validateAllUsersFederated) and keep the current throw while removing the filter.


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');
Expand All @@ -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.
Expand Down Expand Up @@ -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'];
}
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -16,6 +18,38 @@ const getAllowList = mem(
{ maxAge: 60000 },
);

export async function isFederationDomainAllowed(domains: string[]): Promise<boolean> {
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<boolean> {
// 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", ...
Expand Down Expand Up @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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, '-')}`;
}
}
4 changes: 4 additions & 0 deletions ee/packages/federation-matrix/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
6 changes: 6 additions & 0 deletions ee/packages/federation-matrix/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
},
});
}

Expand Down
2 changes: 1 addition & 1 deletion packages/core-services/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ export interface IFederationMatrixService {
notifyUserTyping(rid: string, user: string, isTyping: boolean): Promise<void>;
verifyMatrixIds(matrixIds: string[]): Promise<{ [key: string]: string }>;
handleInvite(subscriptionId: ISubscription['_id'], userId: IUser['_id'], action: 'accept' | 'reject'): Promise<void>;
validateFederatedUsers(usernames: string[]): Promise<void>;
}
23 changes: 21 additions & 2 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:~"
Expand Down Expand Up @@ -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:^"
Expand Down Expand Up @@ -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"
Expand Down
Loading