diff --git a/.changeset/bump-patch-1721226485501.md b/.changeset/bump-patch-1721226485501.md new file mode 100644 index 000000000000..e1eaa7980afb --- /dev/null +++ b/.changeset/bump-patch-1721226485501.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/happy-peaches-nail.md b/.changeset/happy-peaches-nail.md new file mode 100644 index 000000000000..2dfb2151ced0 --- /dev/null +++ b/.changeset/happy-peaches-nail.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed issue with livechat agents not being able to leave omnichannel rooms if joining after a room has been closed by the visitor (due to race conditions) diff --git a/.changeset/hungry-jars-lay.md b/.changeset/hungry-jars-lay.md new file mode 100644 index 000000000000..eacb88108a0f --- /dev/null +++ b/.changeset/hungry-jars-lay.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates) diff --git a/.changeset/thin-windows-reply.md b/.changeset/thin-windows-reply.md new file mode 100644 index 000000000000..1a32e1ddebfb --- /dev/null +++ b/.changeset/thin-windows-reply.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue not displaying all groups in settings list diff --git a/.changeset/violet-brooms-press.md b/.changeset/violet-brooms-press.md new file mode 100644 index 000000000000..632026d6fe2e --- /dev/null +++ b/.changeset/violet-brooms-press.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Security Hotfix (https://docs.rocket.chat/guides/security/security-updates) diff --git a/apps/meteor/app/2fa/server/methods/enable.ts b/apps/meteor/app/2fa/server/methods/enable.ts index 3b9f35dfcd9d..6b786c0743e9 100644 --- a/apps/meteor/app/2fa/server/methods/enable.ts +++ b/apps/meteor/app/2fa/server/methods/enable.ts @@ -34,6 +34,10 @@ Meteor.methods({ }); } + if (user.services?.totp?.enabled) { + throw new Meteor.Error('error-2fa-already-enabled'); + } + const secret = TOTP.generateSecret(); await Users.disable2FAAndSetTempSecretByUserId(userId, secret.base32); diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index c482e3bb784d..3ccc9caeafa0 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -18,6 +18,7 @@ import { executeUpdateMessage } from '../../../lib/server/methods/updateMessage' import { OEmbed } from '../../../oembed/server/server'; import { executeSetReaction } from '../../../reactions/server/setReaction'; import { settings } from '../../../settings/server'; +import { MessageTypes } from '../../../ui-utils/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; @@ -217,6 +218,10 @@ API.v1.addRoute( throw new Meteor.Error('error-invalid-params', 'The "message" parameter must be provided.'); } + if (MessageTypes.isSystemMessage(this.bodyParams.message)) { + throw new Error("Cannot send system messages using 'chat.sendMessage'"); + } + const sent = await executeSendMessage(this.userId, this.bodyParams.message as Pick, this.bodyParams.previewUrls); const [message] = await normalizeMessagesForUser([sent], this.userId); diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index c26957fa1991..410a65fe7eda 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -43,6 +43,7 @@ import { setStatusText } from '../../../lib/server/functions/setStatusText'; import { setUserAvatar } from '../../../lib/server/functions/setUserAvatar'; import { setUsernameWithValidation } from '../../../lib/server/functions/setUsername'; import { validateCustomFields } from '../../../lib/server/functions/validateCustomFields'; +import { validateNameChars } from '../../../lib/server/functions/validateNameChars'; import { notifyOnUserChange, notifyOnUserChangeAsync } from '../../../lib/server/lib/notifyListener'; import { generateAccessToken } from '../../../lib/server/methods/createToken'; import { settings } from '../../../settings/server'; @@ -94,6 +95,10 @@ API.v1.addRoute( async post() { const userData = { _id: this.bodyParams.userId, ...this.bodyParams.data }; + if (userData.name && !validateNameChars(userData.name)) { + return API.v1.failure('Name contains invalid characters'); + } + await saveUser(this.userId, userData); if (this.bodyParams.data.customFields) { @@ -138,6 +143,10 @@ API.v1.addRoute( typedPassword: this.bodyParams.data.currentPassword, }; + if (userData.realname && !validateNameChars(userData.realname)) { + return API.v1.failure('Name contains invalid characters'); + } + // saveUserProfile now uses the default two factor authentication procedures, so we need to provide that const twoFactorOptions = !userData.typedPassword ? null @@ -280,6 +289,10 @@ API.v1.addRoute( this.bodyParams.joinDefaultChannels = true; } + if (this.bodyParams.name && !validateNameChars(this.bodyParams.name)) { + return API.v1.failure('Name contains invalid characters'); + } + if (this.bodyParams.customFields) { validateCustomFields(this.bodyParams.customFields); } @@ -627,16 +640,20 @@ API.v1.addRoute( }, { async post() { + const { secret: secretURL, ...params } = this.bodyParams; + if (this.userId) { return API.v1.failure('Logged in users can not register again.'); } + if (params.name && !validateNameChars(params.name)) { + return API.v1.failure('Name contains invalid characters'); + } + if (!(await checkUsernameAvailability(this.bodyParams.username))) { return API.v1.failure('Username is already in use'); } - const { secret: secretURL, ...params } = this.bodyParams; - if (this.bodyParams.customFields) { try { await validateCustomFields(this.bodyParams.customFields); diff --git a/apps/meteor/app/discussion/client/index.ts b/apps/meteor/app/discussion/client/index.ts index 62e11191b493..7c0a6f72e6cc 100644 --- a/apps/meteor/app/discussion/client/index.ts +++ b/apps/meteor/app/discussion/client/index.ts @@ -1,3 +1,2 @@ // Other UI extensions -import './lib/messageTypes/discussionMessage'; import './createDiscussionMessageAction'; diff --git a/apps/meteor/app/discussion/client/lib/messageTypes/discussionMessage.js b/apps/meteor/app/discussion/client/lib/messageTypes/discussionMessage.js deleted file mode 100644 index a7f0ef0a1d97..000000000000 --- a/apps/meteor/app/discussion/client/lib/messageTypes/discussionMessage.js +++ /dev/null @@ -1,16 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { MessageTypes } from '../../../../ui-utils/client'; - -Meteor.startup(() => { - MessageTypes.registerType({ - id: 'discussion-created', - system: false, - message: 'discussion-created', - data(message) { - return { - message: ` ${message.msg}`, - }; - }, - }); -}); diff --git a/apps/meteor/app/lib/server/functions/checkUrlForSsrf.ts b/apps/meteor/app/lib/server/functions/checkUrlForSsrf.ts new file mode 100644 index 000000000000..c90065d7ad8f --- /dev/null +++ b/apps/meteor/app/lib/server/functions/checkUrlForSsrf.ts @@ -0,0 +1,100 @@ +import { lookup } from 'dns'; + +// https://en.wikipedia.org/wiki/Reserved_IP_addresses + Alibaba Metadata IP +const ranges: string[] = [ + '0.0.0.0/8', + '10.0.0.0/8', + '100.64.0.0/10', + '127.0.0.0/8', + '169.254.0.0/16', + '172.16.0.0/12', + '192.0.0.0/24', + '192.0.2.0/24', + '192.88.99.0/24', + '192.168.0.0/16', + '198.18.0.0/15', + '198.51.100.0/24', + '203.0.113.0/24', + '224.0.0.0/4', + '240.0.0.0/4', + '255.255.255.255', + '100.100.100.200/32', +]; + +export const nslookup = async (hostname: string): Promise => { + return new Promise((resolve, reject) => { + lookup(hostname, (error, address) => { + if (error) { + reject(error); + } else { + resolve(address); + } + }); + }); +}; + +export const ipToLong = (ip: string): number => { + return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0; +}; + +export const isIpInRange = (ip: string, range: string): boolean => { + const [rangeIp, subnet] = range.split('/'); + const ipLong = ipToLong(ip); + const rangeIpLong = ipToLong(rangeIp); + const mask = ~(2 ** (32 - Number(subnet)) - 1); + return (ipLong & mask) === (rangeIpLong & mask); +}; + +export const isIpInAnyRange = (ip: string): boolean => ranges.some((range) => isIpInRange(ip, range)); + +export const isValidIPv4 = (ip: string): boolean => { + const octets = ip.split('.'); + if (octets.length !== 4) return false; + return octets.every((octet) => { + const num = Number(octet); + return num >= 0 && num <= 255 && octet === num.toString(); + }); +}; + +export const isValidDomain = (domain: string): boolean => { + const domainPattern = /^(?!-)(?!.*--)[A-Za-z0-9-]{1,63}(? => { + if (!(url.startsWith('http://') || url.startsWith('https://'))) { + return false; + } + + const [, address] = url.split('://'); + const ipOrDomain = address.includes('/') ? address.split('/')[0] : address; + + if (!(isValidIPv4(ipOrDomain) || isValidDomain(ipOrDomain))) { + return false; + } + + if (isValidIPv4(ipOrDomain) && isIpInAnyRange(ipOrDomain)) { + return false; + } + + if (isValidDomain(ipOrDomain) && /metadata.google.internal/.test(ipOrDomain.toLowerCase())) { + return false; + } + + if (isValidDomain(ipOrDomain)) { + try { + const ipAddress = await nslookup(ipOrDomain); + if (isIpInAnyRange(ipAddress)) { + return false; + } + } catch (error) { + console.log(error); + return false; + } + } + + return true; +}; diff --git a/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts b/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts new file mode 100644 index 000000000000..b716be044d57 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts @@ -0,0 +1,81 @@ +import type { IUser, IRoom, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatRooms, Subscriptions } from '@rocket.chat/models'; + +import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import type { CloseRoomParams } from '../../../livechat/server/lib/LivechatTyped'; +import { Livechat } from '../../../livechat/server/lib/LivechatTyped'; + +export const closeLivechatRoom = async ( + user: IUser, + roomId: IRoom['_id'], + { + comment, + tags, + generateTranscriptPdf, + transcriptEmail, + }: { + comment?: string; + tags?: string[]; + generateTranscriptPdf?: boolean; + transcriptEmail?: + | { + sendToVisitor: false; + } + | { + sendToVisitor: true; + requestData: Pick, 'email' | 'subject'>; + }; + }, +): Promise => { + const room = await LivechatRooms.findOneById(roomId); + if (!room || !isOmnichannelRoom(room)) { + throw new Error('error-invalid-room'); + } + + if (!room.open) { + const subscriptionsLeft = await Subscriptions.countByRoomId(roomId); + if (subscriptionsLeft) { + await Subscriptions.removeByRoomId(roomId); + return; + } + throw new Error('error-room-already-closed'); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, user._id, { projection: { _id: 1 } }); + if (!subscription && !(await hasPermissionAsync(user._id, 'close-others-livechat-room'))) { + throw new Error('error-not-authorized'); + } + + const options: CloseRoomParams['options'] = { + clientAction: true, + tags, + ...(generateTranscriptPdf && { pdfTranscript: { requestedBy: user._id } }), + ...(transcriptEmail && { + ...(transcriptEmail.sendToVisitor + ? { + emailTranscript: { + sendToVisitor: true, + requestData: { + email: transcriptEmail.requestData.email, + subject: transcriptEmail.requestData.subject, + requestedAt: new Date(), + requestedBy: user, + }, + }, + } + : { + emailTranscript: { + sendToVisitor: false, + }, + }), + }), + }; + + await Livechat.closeRoom({ + room, + user, + options, + comment, + }); +}; diff --git a/apps/meteor/app/lib/server/functions/saveUser.js b/apps/meteor/app/lib/server/functions/saveUser.js index 1931333038b6..ef6a7e9fe7bd 100644 --- a/apps/meteor/app/lib/server/functions/saveUser.js +++ b/apps/meteor/app/lib/server/functions/saveUser.js @@ -69,6 +69,13 @@ async function _sendUserEmail(subject, html, userData) { async function validateUserData(userId, userData) { const existingRoles = _.pluck(await getRoles(), '_id'); + if (userData.verified && userData._id && userId === userData._id) { + throw new Meteor.Error('error-action-not-allowed', 'Editing email verification is not allowed', { + method: 'insertOrUpdateUser', + action: 'Editing_user', + }); + } + if (userData._id && userId !== userData._id && !(await hasPermissionAsync(userId, 'edit-other-user-info'))) { throw new Meteor.Error('error-action-not-allowed', 'Editing user is not allowed', { method: 'insertOrUpdateUser', diff --git a/apps/meteor/app/lib/server/functions/setUserAvatar.ts b/apps/meteor/app/lib/server/functions/setUserAvatar.ts index b46f0ff8cd50..13ccd2de6954 100644 --- a/apps/meteor/app/lib/server/functions/setUserAvatar.ts +++ b/apps/meteor/app/lib/server/functions/setUserAvatar.ts @@ -10,6 +10,7 @@ import { hasPermissionAsync } from '../../../authorization/server/functions/hasP import { FileUpload } from '../../../file-upload/server'; import { RocketChatFile } from '../../../file/server'; import { settings } from '../../../settings/server'; +import { checkUrlForSsrf } from './checkUrlForSsrf'; export const setAvatarFromServiceWithValidation = async ( userId: string, @@ -88,8 +89,17 @@ export async function setUserAvatar( const { buffer, type } = await (async (): Promise<{ buffer: Buffer; type: string }> => { if (service === 'url' && typeof dataURI === 'string') { let response: Response; + + const isSsrfSafe = await checkUrlForSsrf(dataURI); + if (!isSsrfSafe) { + throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${encodeURI(dataURI)}`, { + function: 'setUserAvatar', + url: dataURI, + }); + } + try { - response = await fetch(dataURI); + response = await fetch(dataURI, { redirect: 'error' }); } catch (e) { SystemLogger.info(`Not a valid response, from the avatar url: ${encodeURI(dataURI)}`); throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${encodeURI(dataURI)}`, { diff --git a/apps/meteor/app/lib/server/functions/validateNameChars.ts b/apps/meteor/app/lib/server/functions/validateNameChars.ts new file mode 100644 index 000000000000..07330c66b762 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/validateNameChars.ts @@ -0,0 +1,21 @@ +export const validateNameChars = (name: string | undefined): boolean => { + if (typeof name !== 'string') { + return false; + } + + const invalidChars = /[<>\\/]/; + if (invalidChars.test(name)) { + return false; + } + + try { + const decodedName = decodeURI(name); + if (invalidChars.test(decodedName)) { + return false; + } + } catch (err) { + return false; + } + + return true; +}; diff --git a/apps/meteor/app/lib/server/methods/sendMessage.ts b/apps/meteor/app/lib/server/methods/sendMessage.ts index a490b5c4c67f..a61ab499ce87 100644 --- a/apps/meteor/app/lib/server/methods/sendMessage.ts +++ b/apps/meteor/app/lib/server/methods/sendMessage.ts @@ -12,6 +12,7 @@ import { canSendMessageAsync } from '../../../authorization/server/functions/can import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { metrics } from '../../../metrics/server'; import { settings } from '../../../settings/server'; +import { MessageTypes } from '../../../ui-utils/server'; import { sendMessage } from '../functions/sendMessage'; import { RateLimiter } from '../lib'; @@ -78,6 +79,8 @@ export async function executeSendMessage(uid: IUser['_id'], message: AtLeast({ }); } + if (MessageTypes.isSystemMessage(message)) { + throw new Error("Cannot send system messages using 'sendMessage'"); + } + try { return await executeSendMessage(uid, message, previewUrls); } catch (error: any) { diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.ts b/apps/meteor/app/livechat/imports/server/rest/sms.ts index 2551b79cc425..f6502b70f68a 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/sms.ts @@ -17,6 +17,7 @@ import { Meteor } from 'meteor/meteor'; import { getFileExtension } from '../../../../../lib/utils/getFileExtension'; import { API } from '../../../../api/server'; import { FileUpload } from '../../../../file-upload/server'; +import { checkUrlForSsrf } from '../../../../lib/server/functions/checkUrlForSsrf'; import { settings } from '../../../../settings/server'; import type { ILivechatMessage } from '../../../server/lib/LivechatTyped'; import { Livechat as LivechatTyped } from '../../../server/lib/LivechatTyped'; @@ -24,7 +25,12 @@ import { Livechat as LivechatTyped } from '../../../server/lib/LivechatTyped'; const logger = new Logger('SMS'); const getUploadFile = async (details: Omit, fileUrl: string) => { - const response = await fetch(fileUrl); + const isSsrfSafe = await checkUrlForSsrf(fileUrl); + if (!isSsrfSafe) { + throw new Meteor.Error('error-invalid-url', 'Invalid URL'); + } + + const response = await fetch(fileUrl, { redirect: 'error' }); const content = Buffer.from(await response.arrayBuffer()); diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index 2196315ad013..d2a76e53926f 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -1,7 +1,7 @@ import { Omnichannel } from '@rocket.chat/core-services'; import type { ILivechatAgent, IOmnichannelRoom, IUser, SelectedAgent, TransferByData } from '@rocket.chat/core-typings'; import { isOmnichannelRoom, OmnichannelSourceType } from '@rocket.chat/core-typings'; -import { LivechatVisitors, Users, LivechatRooms, Subscriptions, Messages } from '@rocket.chat/models'; +import { LivechatVisitors, Users, LivechatRooms, Messages } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import { isLiveChatRoomForwardProps, @@ -22,6 +22,7 @@ import { isWidget } from '../../../../api/server/helpers/isWidget'; import { canAccessRoomAsync, roomAccessAttributes } from '../../../../authorization/server'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; import { addUserToRoom } from '../../../../lib/server/functions/addUserToRoom'; +import { closeLivechatRoom } from '../../../../lib/server/functions/closeLivechatRoom'; import { settings as rcSettings } from '../../../../settings/server'; import { normalizeTransferredByData } from '../../lib/Helper'; import type { CloseRoomParams } from '../../lib/LivechatTyped'; @@ -177,51 +178,7 @@ API.v1.addRoute( async post() { const { rid, comment, tags, generateTranscriptPdf, transcriptEmail } = this.bodyParams; - const room = await LivechatRooms.findOneById(rid); - if (!room || !isOmnichannelRoom(room)) { - throw new Error('error-invalid-room'); - } - - if (!room.open) { - throw new Error('error-room-already-closed'); - } - - const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, this.userId, { projection: { _id: 1 } }); - if (!subscription && !(await hasPermissionAsync(this.userId, 'close-others-livechat-room'))) { - throw new Error('error-not-authorized'); - } - - const options: CloseRoomParams['options'] = { - clientAction: true, - tags, - ...(generateTranscriptPdf && { pdfTranscript: { requestedBy: this.userId } }), - ...(transcriptEmail && { - ...(transcriptEmail.sendToVisitor - ? { - emailTranscript: { - sendToVisitor: true, - requestData: { - email: transcriptEmail.requestData.email, - subject: transcriptEmail.requestData.subject, - requestedAt: new Date(), - requestedBy: this.user, - }, - }, - } - : { - emailTranscript: { - sendToVisitor: false, - }, - }), - }), - }; - - await LivechatTyped.closeRoom({ - room, - user: this.user, - options, - comment, - }); + await closeLivechatRoom(this.user, rid, { comment, tags, generateTranscriptPdf, transcriptEmail }); return API.v1.success(); }, diff --git a/apps/meteor/app/livechat/server/methods/closeRoom.ts b/apps/meteor/app/livechat/server/methods/closeRoom.ts index 1374d86ab9f7..5fdf9e7d504f 100644 --- a/apps/meteor/app/livechat/server/methods/closeRoom.ts +++ b/apps/meteor/app/livechat/server/methods/closeRoom.ts @@ -60,6 +60,16 @@ Meteor.methods({ }); } + const subscription = await SubscriptionRaw.findOneByRoomIdAndUserId(roomId, userId, { + projection: { + _id: 1, + }, + }); + if (!room.open && subscription) { + await SubscriptionRaw.removeByRoomId(roomId); + return; + } + if (!room.open) { throw new Meteor.Error('room-closed', 'Room closed', { method: 'livechat:closeRoom' }); } @@ -71,11 +81,6 @@ Meteor.methods({ }); } - const subscription = await SubscriptionRaw.findOneByRoomIdAndUserId(roomId, user._id, { - projection: { - _id: 1, - }, - }); if (!subscription && !(await hasPermissionAsync(userId, 'close-others-livechat-room'))) { throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'livechat:closeRoom', diff --git a/apps/meteor/app/ui-utils/lib/MessageTypes.ts b/apps/meteor/app/ui-utils/lib/MessageTypes.ts index a4f77d10cbf7..c108fe55f168 100644 --- a/apps/meteor/app/ui-utils/lib/MessageTypes.ts +++ b/apps/meteor/app/ui-utils/lib/MessageTypes.ts @@ -5,8 +5,6 @@ export type MessageType = { id: MessageTypesValues; system?: boolean; /* deprecated */ - render?: (message: IMessage) => string; - /* deprecated */ template?: (message: IMessage) => unknown; message: TranslationKey; data?: (message: IMessage) => Record; diff --git a/apps/meteor/app/ui-utils/server/Message.ts b/apps/meteor/app/ui-utils/server/Message.ts index 18cf842b1993..06ae59238b42 100644 --- a/apps/meteor/app/ui-utils/server/Message.ts +++ b/apps/meteor/app/ui-utils/server/Message.ts @@ -11,9 +11,6 @@ export const Message = { parse(msg: IMessage, language: string) { const messageType = MessageTypes.getType(msg); if (messageType) { - if (messageType.render) { - return messageType.render(msg); - } if (messageType.template) { // Render message return; diff --git a/apps/meteor/client/components/Page/PageScrollableContent.tsx b/apps/meteor/client/components/Page/PageScrollableContent.tsx index c3ac6869f277..f8c3bb5ba54b 100644 --- a/apps/meteor/client/components/Page/PageScrollableContent.tsx +++ b/apps/meteor/client/components/Page/PageScrollableContent.tsx @@ -1,4 +1,3 @@ -import { css } from '@rocket.chat/css-in-js'; import type { Scrollable } from '@rocket.chat/fuselage'; import { Box } from '@rocket.chat/fuselage'; import type { ComponentProps } from 'react'; @@ -26,17 +25,7 @@ const PageScrollableContent = forwardRef - + ); diff --git a/apps/meteor/client/components/message/variants/SystemMessage.tsx b/apps/meteor/client/components/message/variants/SystemMessage.tsx index e2e1d9bf04bd..eeba342c3f31 100644 --- a/apps/meteor/client/components/message/variants/SystemMessage.tsx +++ b/apps/meteor/client/components/message/variants/SystemMessage.tsx @@ -94,14 +94,9 @@ const SystemMessage = ({ message, showUserAvatar, ...props }: SystemMessageProps )} {messageType && ( - + + {t(messageType.message, messageType.data ? messageType.data(message) : {})} + )} {formatTime(message.ts)} diff --git a/apps/meteor/client/views/admin/settings/inputs/CodeMirror/CodeMirrorBox.tsx b/apps/meteor/client/views/admin/settings/inputs/CodeMirror/CodeMirrorBox.tsx index 3f61417e4fa6..fd96d715a837 100644 --- a/apps/meteor/client/views/admin/settings/inputs/CodeMirror/CodeMirrorBox.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/CodeMirror/CodeMirrorBox.tsx @@ -1,51 +1,55 @@ -import { css } from '@rocket.chat/css-in-js'; import { Box, Button, ButtonGroup } from '@rocket.chat/fuselage'; import { useToggle } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ReactNode } from 'react'; import React from 'react'; +import { createPortal } from 'react-dom'; const CodeMirrorBox = ({ label, children }: { label: ReactNode; children: ReactElement }) => { const t = useTranslation(); const [fullScreen, toggleFullScreen] = useToggle(false); - const fullScreenStyle = css` - position: fixed; - z-index: 100; - top: 0; - right: 0; - bottom: 0; - left: 0; - - display: flex; - - flex-direction: column; - - width: auto; - height: auto; - - padding: 40px; - - align-items: stretch; - `; - - return ( - - {fullScreen && ( + if (fullScreen) { + return createPortal( + {label} - )} + + {children} + + + + + + + , + document.getElementById('main-content') as HTMLElement, + ); + } + + return ( + {children} diff --git a/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useQuickActions.tsx b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useQuickActions.tsx index 7446d0630b09..edf5ffcbdc8a 100644 --- a/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useQuickActions.tsx +++ b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useQuickActions.tsx @@ -10,6 +10,7 @@ import { useMethod, useTranslation, useRouter, + useUserSubscription, } from '@rocket.chat/ui-contexts'; import React, { useCallback, useState, useEffect } from 'react'; @@ -47,6 +48,7 @@ export const useQuickActions = (): { const visitorRoomId = room.v._id; const rid = room._id; const uid = useUserId(); + const subscription = useUserSubscription(rid); const roomLastMessage = room.lastMessage; const getVisitorInfo = useEndpoint('GET', '/v1/livechat/visitors.info'); @@ -330,7 +332,7 @@ export const useQuickActions = (): { case QuickActionsEnum.TranscriptPDF: return hasLicense && !isRoomOverMacLimit && canSendTranscriptPDF; case QuickActionsEnum.CloseChat: - return !!roomOpen && (canCloseRoom || canCloseOthersRoom); + return (subscription && (canCloseRoom || canCloseOthersRoom)) || (!!roomOpen && canCloseOthersRoom); case QuickActionsEnum.OnHoldChat: return !!roomOpen && canPlaceChatOnHold; default: diff --git a/apps/meteor/server/routes/avatar/room.js b/apps/meteor/server/routes/avatar/room.js index c47e58b48d0b..3482253d57d8 100644 --- a/apps/meteor/server/routes/avatar/room.js +++ b/apps/meteor/server/routes/avatar/room.js @@ -5,6 +5,9 @@ import { FileUpload } from '../../../app/file-upload/server'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; import { renderSVGLetters, serveAvatar, wasFallbackModified, setCacheAndDispositionHeaders } from './utils'; +const MAX_ROOM_SVG_AVATAR_SIZE = 1024; +const MIN_ROOM_SVG_AVATAR_SIZE = 16; + const cookie = new Cookies(); const getRoomAvatar = async (roomId) => { const room = await Rooms.findOneById(roomId, { projection: { t: 1, prid: 1, name: 1, fname: 1, federated: 1 } }); @@ -64,7 +67,12 @@ export const roomAvatar = async function (req, res /* , next*/) { return; } - const svg = renderSVGLetters(roomName, req.query.size && parseInt(req.query.size)); + let avatarSize = req.query.size && parseInt(req.query.size); + if (avatarSize) { + avatarSize = Math.min(Math.max(avatarSize, MIN_ROOM_SVG_AVATAR_SIZE), MAX_ROOM_SVG_AVATAR_SIZE); + } + + const svg = renderSVGLetters(roomName, avatarSize); return serveAvatar(svg, req.query.format, res); }; diff --git a/apps/meteor/server/routes/avatar/user.js b/apps/meteor/server/routes/avatar/user.js index 7997a91d95a4..0d86bc4a08cf 100644 --- a/apps/meteor/server/routes/avatar/user.js +++ b/apps/meteor/server/routes/avatar/user.js @@ -4,6 +4,9 @@ import { FileUpload } from '../../../app/file-upload/server'; import { settings } from '../../../app/settings/server'; import { renderSVGLetters, serveAvatar, wasFallbackModified, setCacheAndDispositionHeaders } from './utils'; +const MAX_USER_SVG_AVATAR_SIZE = 1024; +const MIN_USER_SVG_AVATAR_SIZE = 16; + // request /avatar/@name forces returning the svg export const userAvatar = async function (req, res) { const requestUsername = decodeURIComponent(req.url.substr(1).replace(/\?.*$/, '')); @@ -14,7 +17,10 @@ export const userAvatar = async function (req, res) { return; } - const avatarSize = req.query.size && parseInt(req.query.size); + let avatarSize = req.query.size && parseInt(req.query.size); + if (avatarSize) { + avatarSize = Math.min(Math.max(avatarSize, MIN_USER_SVG_AVATAR_SIZE), MAX_USER_SVG_AVATAR_SIZE); + } setCacheAndDispositionHeaders(req, res); diff --git a/apps/meteor/tests/end-to-end/api/01-users.js b/apps/meteor/tests/end-to-end/api/01-users.js index 07e7661e8c43..535e989dce95 100644 --- a/apps/meteor/tests/end-to-end/api/01-users.js +++ b/apps/meteor/tests/end-to-end/api/01-users.js @@ -472,6 +472,23 @@ describe('[Users]', function () { }) .end(done); }); + it("should return an error when registering a user's name with invalid characters: >, <, /, or \\", (done) => { + request + .post(api('users.register')) + .send({ + email, + name: '', + username, + pass: 'test', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').and.to.be.equal('Name contains invalid characters'); + }) + .end(done); + }); }); describe('[/users.info]', () => { @@ -1171,6 +1188,21 @@ describe('[Users]', function () { }); }); }); + it('should prevent users from passing server-side request forgery (SSRF) payloads as avatarUrl', (done) => { + request + .post(api('users.setAvatar')) + .set(credentials) + .send({ + userId: userCredentials['X-User-Id'], + avatarUrl: 'http://169.254.169.254/', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }) + .end(done); + }); }); describe('[/users.resetAvatar]', () => { diff --git a/apps/meteor/tests/end-to-end/api/05-chat.js b/apps/meteor/tests/end-to-end/api/05-chat.js index 96aa276e1a9c..1433b8f28145 100644 --- a/apps/meteor/tests/end-to-end/api/05-chat.js +++ b/apps/meteor/tests/end-to-end/api/05-chat.js @@ -1,3 +1,4 @@ +import { Random } from '@rocket.chat/random'; import { expect } from 'chai'; import { after, before, beforeEach, describe, it } from 'mocha'; @@ -1103,6 +1104,27 @@ describe('[Chat]', function () { .end(done); }); + it('should fail if message is a system message', () => { + const msgId = Random.id(); + return request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + _id: msgId, + rid: 'GENERAL', + msg: 'xss', + t: 'subscription-role-added', + role: '

XSS', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + describe('customFields', () => { async function testMessageSending({ customFields, testCb, statusCode }) { await request diff --git a/apps/meteor/tests/end-to-end/api/24-methods.js b/apps/meteor/tests/end-to-end/api/24-methods.js index dc1d506f1317..aabd24e04119 100644 --- a/apps/meteor/tests/end-to-end/api/24-methods.js +++ b/apps/meteor/tests/end-to-end/api/24-methods.js @@ -1,3 +1,4 @@ +import { Random } from '@rocket.chat/random'; import { expect } from 'chai'; import { after, before, beforeEach, describe, it } from 'mocha'; @@ -1978,6 +1979,46 @@ describe('Meteor.methods', function () { }) .end(done); }); + + it('should not send message if it is a system message', async () => { + const msgId = Random.id(); + await request + .post(methodCall('sendMessage')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'sendMessage', + params: [ + { + _id: msgId, + rid: 'GENERAL', + msg: 'xss', + t: 'subscription-role-added', + role: '

XSS', + }, + ], + id: 1000, + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const data = JSON.parse(res.body.message); + expect(data).to.not.have.a.property('result').that.is.an('object'); + expect(data).to.have.a.property('error').that.is.an('object'); + }); + await request + .get(api('chat.getMessage')) + .set(credentials) + .query({ msgId }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[@updateMessage]', () => { @@ -3173,4 +3214,78 @@ describe('Meteor.methods', function () { }); }); }); + + describe('@insertOrUpdateUser', () => { + let testUser; + let testUserCredentials; + + before(async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + }); + + after(() => Promise.all([deleteUser(testUser)])); + + it('should fail if user tries to verify their own email via insertOrUpdateUser', (done) => { + request + .post(methodCall('insertOrUpdateUser')) + .set(testUserCredentials) + .send({ + message: JSON.stringify({ + method: 'insertOrUpdateUser', + params: [ + { + _id: testUserCredentials['X-User-Id'], + email: 'manager@rocket.chat', + verified: true, + }, + ], + id: '52', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('msg', 'result'); + expect(data).to.have.a.property('id', '52'); + expect(data.error).to.have.property('error', 'error-action-not-allowed'); + }) + .end(done); + }); + + it('should pass if a user with the right permissions tries to verify the email of another user', (done) => { + request + .post(methodCall('insertOrUpdateUser')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'insertOrUpdateUser', + params: [ + { + _id: testUserCredentials['X-User-Id'], + email: 'testuser@rocket.chat', + verified: true, + }, + ], + id: '52', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + expect(res.body).to.have.a.property('message').that.is.a('string'); + const data = JSON.parse(res.body.message); + expect(data).to.have.a.property('msg', 'result'); + expect(data).to.have.a.property('id', '52'); + expect(data).to.have.a.property('result', true); + }) + .end(done); + }); + }); }); diff --git a/apps/meteor/tests/end-to-end/api/methods/2fa-enable.ts b/apps/meteor/tests/end-to-end/api/methods/2fa-enable.ts new file mode 100644 index 000000000000..a2bd9dfeb3a7 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/methods/2fa-enable.ts @@ -0,0 +1,158 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { expect } from 'chai'; +import { before, describe, it, after } from 'mocha'; +import speakeasy from 'speakeasy'; + +import { getCredentials, methodCall, request } from '../../../data/api-data'; +import { password } from '../../../data/user'; +import { createUser, deleteUser, login } from '../../../data/users.helper'; + +describe('2fa:enable', function () { + this.retries(0); + let totpSecret: string; + let user1: IUser; + let user2: IUser; + let user3: IUser; + let user1Credentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let user2Credentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + let user3Credentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + + before((done) => getCredentials(done)); + + before('create user', async () => { + [user1, user2, user3] = await Promise.all([ + createUser({ username: Random.id(), email: `${Random.id()}@example.com`, verified: true }), + createUser({ username: Random.id(), email: `${Random.id()}@example.com}`, verified: true }), + createUser({ username: Random.id(), email: `${Random.id()}@example.com}`, verified: false }), + ]); + [user1Credentials, user2Credentials, user3Credentials] = await Promise.all([ + login(user1.username, password), + login(user2.username, password), + login(user3.username, password), + ]); + }); + + after('remove user', async () => Promise.all([deleteUser(user1), deleteUser(user2), deleteUser(user3)])); + + it('should return error when user is not logged in', async () => { + await request + .post(methodCall('2fa:enable')) + .send({ + message: JSON.stringify({ + msg: 'method', + id: 'id1', + method: '2fa:enable', + params: [], + }), + }) + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return error when user is not verified', async () => { + await request + .post(methodCall('2fa:enable')) + .set(user3Credentials) + .send({ + message: JSON.stringify({ + msg: 'method', + id: 'id1', + method: '2fa:enable', + params: [], + }), + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('message'); + const result = JSON.parse(res.body.message); + expect(result).to.have.property('error'); + expect(result.error).to.not.have.property('errpr', 'error-invalid-user'); + }); + }); + + it('should return secret and qr code url when 2fa is disabled on user', async () => { + await request + .post(methodCall('2fa:enable')) + .set(user1Credentials) + .send({ + message: JSON.stringify({ + msg: 'method', + id: 'id1', + method: '2fa:enable', + params: [], + }), + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const parsedBody = JSON.parse(res.body.message); + expect(parsedBody).to.have.property('result'); + expect(parsedBody.result).to.have.property('secret').of.a('string'); + expect(parsedBody.result) + .to.have.property('url') + .of.a('string') + .match(/^otpauth:\/\//); + }); + }); + + it('should enable 2fa on the user', async () => { + const enableResponse = await request + .post(methodCall('2fa:enable')) + .set(user2Credentials) + .send({ + message: JSON.stringify({ + msg: 'method', + id: 'id2', + method: '2fa:enable', + params: [], + }), + }) + .expect(200); + + const enableData = JSON.parse(enableResponse.body.message); + totpSecret = enableData.result.secret; + + await request + .post(methodCall('2fa:validateTempToken')) + .set(user2Credentials) + .send({ + message: JSON.stringify({ + msg: 'method', + id: 'id3', + method: '2fa:validateTempToken', + params: [speakeasy.totp({ secret: totpSecret, encoding: 'base32' })], + }), + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const parsedBody = JSON.parse(res.body.message); + expect(parsedBody).to.have.property('result'); + expect(parsedBody.result).to.have.property('codes').of.a('array'); + }); + }); + + it('should return error when 2fa is already enabled on the user', async () => { + await request + .post(methodCall('2fa:enable')) + .set(user2Credentials) + .send({ + message: JSON.stringify({ + msg: 'method', + id: 'id4', + method: '2fa:enable', + params: [], + }), + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const parsedBody = JSON.parse(res.body.message); + expect(parsedBody).to.have.property('error'); + expect(parsedBody).to.not.have.property('result'); + }); + }); +}); diff --git a/apps/meteor/tests/unit/app/lib/server/functions/closeLivechatRoom.tests.ts b/apps/meteor/tests/unit/app/lib/server/functions/closeLivechatRoom.tests.ts new file mode 100644 index 000000000000..07ee437832d2 --- /dev/null +++ b/apps/meteor/tests/unit/app/lib/server/functions/closeLivechatRoom.tests.ts @@ -0,0 +1,154 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import { createFakeRoom, createFakeSubscription, createFakeUser } from '../../../../../mocks/data'; + +const subscriptionsStub = { + findOneByRoomIdAndUserId: sinon.stub(), + removeByRoomId: sinon.stub(), + countByRoomId: sinon.stub(), +}; + +const livechatRoomsStub = { + findOneById: sinon.stub(), +}; + +const livechatStub = { + closeRoom: sinon.stub(), +}; + +const hasPermissionStub = sinon.stub(); + +const { closeLivechatRoom } = proxyquire.noCallThru().load('../../../../../../app/lib/server/functions/closeLivechatRoom.ts', { + '../../../livechat/server/lib/LivechatTyped': { + Livechat: livechatStub, + }, + '../../../authorization/server/functions/hasPermission': { + hasPermissionAsync: hasPermissionStub, + }, + '@rocket.chat/models': { + Subscriptions: subscriptionsStub, + LivechatRooms: livechatRoomsStub, + }, +}); + +describe('closeLivechatRoom', () => { + const user = createFakeUser(); + const room = createFakeRoom({ t: 'l', open: true }); + const subscription = createFakeSubscription({ rid: room._id, u: { username: user.username, _id: user._id } }); + + beforeEach(() => { + subscriptionsStub.findOneByRoomIdAndUserId.reset(); + subscriptionsStub.removeByRoomId.reset(); + subscriptionsStub.countByRoomId.reset(); + livechatRoomsStub.findOneById.reset(); + livechatStub.closeRoom.reset(); + hasPermissionStub.reset(); + }); + + it('should not perform any operation when an invalid room id is provided', async () => { + livechatRoomsStub.findOneById.resolves(null); + hasPermissionStub.resolves(true); + + await expect(closeLivechatRoom(user, room._id, {})).to.be.rejectedWith('error-invalid-room'); + expect(livechatStub.closeRoom.notCalled).to.be.true; + expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true; + expect(subscriptionsStub.findOneByRoomIdAndUserId.notCalled).to.be.true; + expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true; + }); + + it('should not perform any operation when a non-livechat room is provided', async () => { + livechatRoomsStub.findOneById.resolves({ ...room, t: 'c' }); + subscriptionsStub.findOneByRoomIdAndUserId.resolves(subscription); + hasPermissionStub.resolves(true); + + await expect(closeLivechatRoom(user, room._id, {})).to.be.rejectedWith('error-invalid-room'); + expect(livechatStub.closeRoom.notCalled).to.be.true; + expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true; + expect(subscriptionsStub.findOneByRoomIdAndUserId.notCalled).to.be.true; + expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true; + }); + + it('should not perform any operation when a closed room with no subscriptions is provided and the caller is not subscribed to it', async () => { + livechatRoomsStub.findOneById.resolves({ ...room, open: false }); + subscriptionsStub.countByRoomId.resolves(0); + subscriptionsStub.findOneByRoomIdAndUserId.resolves(null); + hasPermissionStub.resolves(true); + + await expect(closeLivechatRoom(user, room._id, {})).to.be.rejectedWith('error-room-already-closed'); + expect(livechatStub.closeRoom.notCalled).to.be.true; + expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true; + expect(subscriptionsStub.findOneByRoomIdAndUserId.notCalled).to.be.true; + expect(subscriptionsStub.countByRoomId.calledOnceWith(room._id)).to.be.true; + expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true; + }); + + it('should remove dangling subscription when a closed room with subscriptions is provided and the caller is not subscribed to it', async () => { + livechatRoomsStub.findOneById.resolves({ ...room, open: false }); + subscriptionsStub.countByRoomId.resolves(1); + subscriptionsStub.findOneByRoomIdAndUserId.resolves(null); + hasPermissionStub.resolves(true); + + await closeLivechatRoom(user, room._id, {}); + expect(livechatStub.closeRoom.notCalled).to.be.true; + expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true; + expect(subscriptionsStub.findOneByRoomIdAndUserId.notCalled).to.be.true; + expect(subscriptionsStub.countByRoomId.calledOnceWith(room._id)).to.be.true; + expect(subscriptionsStub.removeByRoomId.calledOnceWith(room._id)).to.be.true; + }); + + it('should remove dangling subscription when a closed room is provided but the user is still subscribed to it', async () => { + livechatRoomsStub.findOneById.resolves({ ...room, open: false }); + subscriptionsStub.findOneByRoomIdAndUserId.resolves(subscription); + subscriptionsStub.countByRoomId.resolves(1); + hasPermissionStub.resolves(true); + + await closeLivechatRoom(user, room._id, {}); + expect(livechatStub.closeRoom.notCalled).to.be.true; + expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true; + expect(subscriptionsStub.findOneByRoomIdAndUserId.notCalled).to.be.true; + expect(subscriptionsStub.countByRoomId.calledOnceWith(room._id)).to.be.true; + expect(subscriptionsStub.removeByRoomId.calledOnceWith(room._id)).to.be.true; + }); + + it('should not perform any operation when the caller is not subscribed to an open room and does not have the permission to close others rooms', async () => { + livechatRoomsStub.findOneById.resolves(room); + subscriptionsStub.findOneByRoomIdAndUserId.resolves(null); + subscriptionsStub.countByRoomId.resolves(1); + hasPermissionStub.resolves(false); + + await expect(closeLivechatRoom(user, room._id, {})).to.be.rejectedWith('error-not-authorized'); + expect(livechatStub.closeRoom.notCalled).to.be.true; + expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true; + expect(subscriptionsStub.findOneByRoomIdAndUserId.calledOnceWith(room._id, user._id)).to.be.true; + expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true; + }); + + it('should close the room when the caller is not subscribed to it but has the permission to close others rooms', async () => { + livechatRoomsStub.findOneById.resolves(room); + subscriptionsStub.findOneByRoomIdAndUserId.resolves(null); + subscriptionsStub.countByRoomId.resolves(1); + hasPermissionStub.resolves(true); + + await closeLivechatRoom(user, room._id, {}); + expect(livechatStub.closeRoom.calledOnceWith(sinon.match({ room, user }))).to.be.true; + expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true; + expect(subscriptionsStub.findOneByRoomIdAndUserId.calledOnceWith(room._id, user._id)).to.be.true; + expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true; + }); + + it('should close the room when the caller is subscribed to it and does not have the permission to close others rooms', async () => { + livechatRoomsStub.findOneById.resolves(room); + subscriptionsStub.findOneByRoomIdAndUserId.resolves(subscription); + subscriptionsStub.countByRoomId.resolves(1); + hasPermissionStub.resolves(false); + + await closeLivechatRoom(user, room._id, {}); + expect(livechatStub.closeRoom.calledOnceWith(sinon.match({ room, user }))).to.be.true; + expect(livechatRoomsStub.findOneById.calledOnceWith(room._id)).to.be.true; + expect(subscriptionsStub.findOneByRoomIdAndUserId.calledOnceWith(room._id, user._id)).to.be.true; + expect(subscriptionsStub.removeByRoomId.notCalled).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/app/lib/server/lib/checkUrlForSsrf.tests.ts b/apps/meteor/tests/unit/app/lib/server/lib/checkUrlForSsrf.tests.ts new file mode 100644 index 000000000000..9cb7f1cd288c --- /dev/null +++ b/apps/meteor/tests/unit/app/lib/server/lib/checkUrlForSsrf.tests.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai'; + +import { checkUrlForSsrf } from '../../../../../../app/lib/server/functions/checkUrlForSsrf'; + +describe('checkUrlForSsrf', () => { + it('should return false if the URL does not start with http:// or https://', async () => { + const result = await checkUrlForSsrf('ftp://example.com'); + expect(result).to.be.false; + }); + + it('should return false if the domain is not valid', async () => { + const result = await checkUrlForSsrf('https://www_google_com'); + expect(result).to.be.false; + }); + + it('should return false if the IP is not in a valid IPv4 format', async () => { + const result = await checkUrlForSsrf('https://127.1'); + expect(result).to.be.false; + }); + + it('should return false if the IP is in a restricted range', async () => { + const result = await checkUrlForSsrf('http://127.0.0.1'); + expect(result).to.be.false; + }); + + it('should return false if the domain is metadata.google.internal', async () => { + const result = await checkUrlForSsrf('http://metadata.google.internal'); + expect(result).to.be.false; + }); + + it('should return false if DNS resolves to an IP in the restricted range', async () => { + const result = await checkUrlForSsrf('http://169.254.169.254.nip.io'); + expect(result).to.be.false; + }); + + it('should return true if valid domain', async () => { + const result = await checkUrlForSsrf('https://www.google.com/'); + expect(result).to.be.true; + }); + + it('should return true if valid IP', async () => { + const result = await checkUrlForSsrf('http://216.58.214.174'); + expect(result).to.be.true; + }); + + it('should return true if valid URL', async () => { + const result = await checkUrlForSsrf( + 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/15/Cat_August_2010-4.jpg/2560px-Cat_August_2010-4.jpg', + ); + expect(result).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/app/lib/server/lib/validateNameChars.tests.ts b/apps/meteor/tests/unit/app/lib/server/lib/validateNameChars.tests.ts new file mode 100644 index 000000000000..a78a0cbc3322 --- /dev/null +++ b/apps/meteor/tests/unit/app/lib/server/lib/validateNameChars.tests.ts @@ -0,0 +1,56 @@ +import { expect } from 'chai'; + +import { validateNameChars } from '../../../../../../app/lib/server/functions/validateNameChars'; + +describe('validateNameChars', () => { + it('should return false for undefined input', () => { + expect(validateNameChars(undefined)).to.be.false; + }); + + it('should return false for non-string input', () => { + expect(validateNameChars(123 as any)).to.be.false; + expect(validateNameChars({} as any)).to.be.false; + expect(validateNameChars([] as any)).to.be.false; + }); + + it('should return false for names with invalid characters', () => { + expect(validateNameChars('name<')).to.be.false; + expect(validateNameChars('name>')).to.be.false; + expect(validateNameChars('name/')).to.be.false; + expect(validateNameChars('name\\')).to.be.false; + }); + + it('should return false for names with invalid characters after decoding', () => { + expect(validateNameChars('name%3E')).to.be.false; + expect(validateNameChars('name%5C')).to.be.false; + expect(validateNameChars('name%3C')).to.be.false; + }); + + it('should return false for malicious HTML payloads', () => { + expect(validateNameChars('')).to.be.false; + expect(validateNameChars('%3Cscript%3Ealert%28%27XSS%27%29%3C%2Fscript%3E')).to.be.false; + expect( + validateNameChars( + '
', + ), + ).to.be.false; + expect( + validateNameChars( + '%3Cform%20action%3D%22http%3A%2F%2Fmalicious.site%22%20method%3D%22post%22%3E%3Cinput%20type%3D%22text%22%20name%3D%22username%22%20value%3D%22Enter%20username%22%3E%3Cinput%20type%3D%22password%22%20name%3D%22password%22%20value%3D%22Enter%20password%22%3E%3Cinput%20type%3D%22submit%22%20value%3D%22Submit%22%3E%3C%2Fform%3E', + ), + ).to.be.false; + }); + + it('should return false if decodeURI throws an error', () => { + expect(validateNameChars('%')).to.be.false; + expect(validateNameChars('%E0%A4%A')).to.be.false; + }); + + it('should return true for valid names', () => { + expect(validateNameChars('name')).to.be.true; + expect(validateNameChars('valid_name')).to.be.true; + expect(validateNameChars('valid-name')).to.be.true; + expect(validateNameChars('valid.name')).to.be.true; + expect(validateNameChars('valid name')).to.be.true; + }); +}); diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 46b91babb6fd..f9f29f1fcc30 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -6102,6 +6102,7 @@ "registration.component.form.emailOrUsername": "Email or username", "registration.component.form.username": "Username", "registration.component.form.name": "Name", + "registration.component.form.nameContainsInvalidChars": "Name contains invalid characters", "registration.component.form.nameOptional": "Name optional", "registration.component.form.createAnAccount": "Create an account", "registration.component.form.userAlreadyExist": "Username already exists. Please try another username.", diff --git a/packages/i18n/src/locales/pt-BR.i18n.json b/packages/i18n/src/locales/pt-BR.i18n.json index ce98362e9d40..6af71f8bb30e 100644 --- a/packages/i18n/src/locales/pt-BR.i18n.json +++ b/packages/i18n/src/locales/pt-BR.i18n.json @@ -4904,6 +4904,7 @@ "registration.component.resetPassword": "Redefinir senha", "registration.component.form.username": "Nome de usuário", "registration.component.form.name": "Nome", + "registration.component.form.nameContainsInvalidChars": "O nome contém caracteres inválidos", "registration.component.form.userAlreadyExist": "O nome de usuário já existe. Tente outro nome de usuário.", "registration.component.form.emailAlreadyExists": "E-mail já existe", "registration.component.form.usernameAlreadyExists": "O nome de usuário já existe. Tente outro nome de usuário.", diff --git a/packages/i18n/src/locales/pt.i18n.json b/packages/i18n/src/locales/pt.i18n.json index 2bc58d7fc19c..02f5db9c0044 100644 --- a/packages/i18n/src/locales/pt.i18n.json +++ b/packages/i18n/src/locales/pt.i18n.json @@ -3161,6 +3161,7 @@ "registration.component.form.emailOrUsername": "Email ou nome de utilizador", "registration.component.form.username": "Nome de utilizador", "registration.component.form.name": "Nome", + "registration.component.form.nameContainsInvalidChars": "O nome contém caracteres inválidos", "registration.component.form.userAlreadyExist": "O nome de utilizador já existe. Por favor, tente outro nome de utilizador.", "registration.component.form.emailAlreadyExists": "Email já registado", "registration.component.form.usernameAlreadyExists": "O nome de utilizador já existe. Por favor, tente outro nome de utilizador.", diff --git a/packages/web-ui-registration/src/RegisterForm.tsx b/packages/web-ui-registration/src/RegisterForm.tsx index 0eda77879be7..57cf9378ab72 100644 --- a/packages/web-ui-registration/src/RegisterForm.tsx +++ b/packages/web-ui-registration/src/RegisterForm.tsx @@ -94,14 +94,15 @@ export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRo if (error.errorType === 'error-user-already-exists') { setError('username', { type: 'user-already-exists', message: t('registration.component.form.usernameAlreadyExists') }); } - if (/Email already exists/.test(error.error)) { setError('email', { type: 'email-already-exists', message: t('registration.component.form.emailAlreadyExists') }); } - if (/Username is already in use/.test(error.error)) { setError('username', { type: 'username-already-exists', message: t('registration.component.form.userAlreadyExist') }); } + if (/Name contains invalid characters/.test(error.error)) { + setError('name', { type: 'name-contains-invalid-chars', message: t('registration.component.form.nameContainsInvalidChars') }); + } if (/error-too-many-requests/.test(error.error)) { dispatchToastMessage({ type: 'error', message: error.error }); } diff --git a/yarn.lock b/yarn.lock index 8c0e70af327f..0db2fc4c65d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8967,10 +8967,10 @@ __metadata: "@rocket.chat/icons": "*" "@rocket.chat/prettier-config": "*" "@rocket.chat/styled": "*" - "@rocket.chat/ui-avatar": 4.0.0-rc.7 - "@rocket.chat/ui-contexts": 8.0.0-rc.7 - "@rocket.chat/ui-kit": 0.35.0-rc.0 - "@rocket.chat/ui-video-conf": 8.0.0-rc.7 + "@rocket.chat/ui-avatar": 4.0.0 + "@rocket.chat/ui-contexts": 8.0.0 + "@rocket.chat/ui-kit": 0.35.0 + "@rocket.chat/ui-video-conf": 8.0.0 "@tanstack/react-query": "*" react: "*" react-dom: "*" @@ -9059,8 +9059,8 @@ __metadata: "@rocket.chat/fuselage-tokens": "*" "@rocket.chat/message-parser": 0.31.29 "@rocket.chat/styled": "*" - "@rocket.chat/ui-client": 8.0.0-rc.7 - "@rocket.chat/ui-contexts": 8.0.0-rc.7 + "@rocket.chat/ui-client": 8.0.0 + "@rocket.chat/ui-contexts": 8.0.0 katex: "*" react: "*" languageName: unknown @@ -10278,7 +10278,7 @@ __metadata: typescript: ~5.3.3 peerDependencies: "@rocket.chat/fuselage": "*" - "@rocket.chat/ui-contexts": 8.0.0-rc.7 + "@rocket.chat/ui-contexts": 8.0.0 react: ~17.0.2 languageName: unknown linkType: soft @@ -10331,7 +10331,7 @@ __metadata: "@rocket.chat/fuselage": "*" "@rocket.chat/fuselage-hooks": "*" "@rocket.chat/icons": "*" - "@rocket.chat/ui-contexts": 8.0.0-rc.7 + "@rocket.chat/ui-contexts": 8.0.0 react: ~17.0.2 languageName: unknown linkType: soft @@ -10507,8 +10507,8 @@ __metadata: "@rocket.chat/fuselage-hooks": "*" "@rocket.chat/icons": "*" "@rocket.chat/styled": "*" - "@rocket.chat/ui-avatar": 4.0.0-rc.7 - "@rocket.chat/ui-contexts": 8.0.0-rc.7 + "@rocket.chat/ui-avatar": 4.0.0 + "@rocket.chat/ui-contexts": 8.0.0 react: ^17.0.2 react-dom: ^17.0.2 languageName: unknown @@ -10598,7 +10598,7 @@ __metadata: peerDependencies: "@rocket.chat/layout": "*" "@rocket.chat/tools": 0.2.1 - "@rocket.chat/ui-contexts": 8.0.0-rc.7 + "@rocket.chat/ui-contexts": 8.0.0 "@tanstack/react-query": "*" react: "*" react-hook-form: "*"