From 4e0b091ef808d52d1821a6490e1b293830dd1a9b Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:37:46 +0530 Subject: [PATCH] fix: Prevent unsupported file types on clipboard paste (#13182) # Pull Request Template ## Description This PR adds file type validation for clipboard-pasted attachments and prevents unsupported file types from being attached across channels. https://developers.chatwoot.com/self-hosted/supported-features#outgoing-attachments-supported-file-types Fixes https://linear.app/chatwoot/issue/CW-6233/bug-unsupported-file-types-allowed-via-clipboard-paste ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? **Loom video** **Before** https://www.loom.com/share/882c335be4894d86b9e149d9f7560e72 **After** https://www.loom.com/share/90ad9605fc4446afb94a5b8bbe48f7db ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth --- .../widgets/conversation/ReplyBox.vue | 20 ++ .../i18n/locale/en/conversation.json | 1 + app/javascript/shared/helpers/FileHelper.js | 56 ++++++ .../shared/helpers/specs/FileHelper.spec.js | 184 ++++++++++++++++++ 4 files changed, 261 insertions(+) diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index a2e7fe103..6eef4a122 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -45,6 +45,7 @@ import { removeSignature, getEffectiveChannelType, } from 'dashboard/helper/editorHelper'; +import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LocalStorage } from 'shared/helpers/localStorage'; @@ -637,6 +638,25 @@ export default { // Filter valid files (non-zero size) Array.from(e.clipboardData.files) .filter(file => file.size > 0) + .filter(file => { + const isAllowed = isFileTypeAllowedForChannel(file, { + channelType: this.channelType || this.inbox?.channel_type, + medium: this.inbox?.medium, + conversationType: this.conversationType, + isInstagramChannel: this.isAnInstagramChannel, + isOnPrivateNote: this.isOnPrivateNote, + }); + + if (!isAllowed) { + useAlert( + this.$t('CONVERSATION.FILE_TYPE_NOT_SUPPORTED', { + fileName: file.name, + }) + ); + } + + return isAllowed; + }) .forEach(file => { const { name, type, size } = file; this.onFileUpload({ name, type, size, file }); diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index ec355e412..65d727b54 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -247,6 +247,7 @@ "SUCCESS_DELETE_CONVERSATION": "Conversation deleted successfully", "FAIL_DELETE_CONVERSATION": "Couldn't delete conversation! Try again", "FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit", + "FILE_TYPE_NOT_SUPPORTED": "This {fileName} file type is not supported in this conversation", "MESSAGE_ERROR": "Unable to send this message, please try again later", "SENT_BY": "Sent by:", "BOT": "Bot", diff --git a/app/javascript/shared/helpers/FileHelper.js b/app/javascript/shared/helpers/FileHelper.js index 3866fb6b4..2616c868a 100644 --- a/app/javascript/shared/helpers/FileHelper.js +++ b/app/javascript/shared/helpers/FileHelper.js @@ -1,3 +1,7 @@ +import { getAllowedFileTypesByChannel } from '@chatwoot/utils'; +import { INBOX_TYPES } from 'dashboard/helper/inbox'; +import { ALLOWED_FILE_TYPES } from 'shared/constants/messages'; + export const DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE = 40; export const formatBytes = (bytes, decimals = 2) => { @@ -31,3 +35,55 @@ export const resolveMaximumFileUploadSize = value => { return parsedValue; }; + +/** + * Validates if a file type is allowed for a specific channel + * @param {File} file - The file to validate + * @param {Object} options - Validation options + * @param {string} options.channelType - The channel type + * @param {string} options.medium - The channel medium + * @param {string} options.conversationType - The conversation type (for Instagram DM detection) + * @param {boolean} options.isInstagramChannel - Whether it's an Instagram channel + * @param {boolean} options.isOnPrivateNote - Whether composing a private note (uses broader file type list) + * @returns {boolean} - True if file type is allowed, false otherwise + */ +export const isFileTypeAllowedForChannel = (file, options = {}) => { + if (!file || file.size === 0) return false; + + const { + channelType: originalChannelType, + medium, + conversationType, + isInstagramChannel, + isOnPrivateNote, + } = options; + + // Use broader file types for private notes (matches file picker behavior) + const allowedFileTypes = isOnPrivateNote + ? ALLOWED_FILE_TYPES + : getAllowedFileTypesByChannel({ + channelType: + isInstagramChannel || conversationType === 'instagram_direct_message' + ? INBOX_TYPES.INSTAGRAM + : originalChannelType, + medium, + }); + + // Convert to array and validate + const allowedTypesArray = allowedFileTypes.split(',').map(t => t.trim()); + const fileExtension = `.${file.name.split('.').pop()}`; + + return allowedTypesArray.some(allowedType => { + // Check for exact file extension match + if (allowedType === fileExtension) return true; + + // Check for wildcard MIME type (e.g., image/*) + if (allowedType.endsWith('/*')) { + const prefix = allowedType.slice(0, -2); // Remove '/*' + return file.type.startsWith(prefix + '/'); + } + + // Check for exact MIME type match + return allowedType === file.type; + }); +}; diff --git a/app/javascript/shared/helpers/specs/FileHelper.spec.js b/app/javascript/shared/helpers/specs/FileHelper.spec.js index e8abe506a..1d03be1f8 100644 --- a/app/javascript/shared/helpers/specs/FileHelper.spec.js +++ b/app/javascript/shared/helpers/specs/FileHelper.spec.js @@ -4,6 +4,7 @@ import { fileSizeInMegaBytes, checkFileSizeLimit, resolveMaximumFileUploadSize, + isFileTypeAllowedForChannel, } from '../FileHelper'; describe('#File Helpers', () => { @@ -61,4 +62,187 @@ describe('#File Helpers', () => { expect(resolveMaximumFileUploadSize(75)).toBe(75); }); }); + + describe('isFileTypeAllowedForChannel', () => { + describe('edge cases', () => { + it('should return false for null file', () => { + expect(isFileTypeAllowedForChannel(null)).toBe(false); + }); + + it('should return false for undefined file', () => { + expect(isFileTypeAllowedForChannel(undefined)).toBe(false); + }); + + it('should return false for file with zero size', () => { + const file = { name: 'test.png', type: 'image/png', size: 0 }; + expect(isFileTypeAllowedForChannel(file)).toBe(false); + }); + }); + + describe('wildcard MIME types', () => { + it('should allow image/png when image/* is allowed', () => { + const file = { name: 'test.png', type: 'image/png', size: 1000 }; + expect( + isFileTypeAllowedForChannel(file, { + channelType: 'Channel::WebWidget', + }) + ).toBe(true); + }); + + it('should allow image/jpeg when image/* is allowed', () => { + const file = { name: 'test.jpg', type: 'image/jpeg', size: 1000 }; + expect( + isFileTypeAllowedForChannel(file, { + channelType: 'Channel::WebWidget', + }) + ).toBe(true); + }); + + it('should allow audio/mp3 when audio/* is allowed', () => { + const file = { name: 'test.mp3', type: 'audio/mp3', size: 1000 }; + expect( + isFileTypeAllowedForChannel(file, { + channelType: 'Channel::WebWidget', + }) + ).toBe(true); + }); + + it('should allow video/mp4 when video/* is allowed', () => { + const file = { name: 'test.mp4', type: 'video/mp4', size: 1000 }; + expect( + isFileTypeAllowedForChannel(file, { + channelType: 'Channel::WebWidget', + }) + ).toBe(true); + }); + }); + + describe('exact MIME types', () => { + it('should allow application/pdf when explicitly allowed', () => { + const file = { name: 'test.pdf', type: 'application/pdf', size: 1000 }; + expect( + isFileTypeAllowedForChannel(file, { + channelType: 'Channel::WebWidget', + }) + ).toBe(true); + }); + + it('should allow text/plain when explicitly allowed', () => { + const file = { name: 'test.txt', type: 'text/plain', size: 1000 }; + expect( + isFileTypeAllowedForChannel(file, { + channelType: 'Channel::WebWidget', + }) + ).toBe(true); + }); + }); + + describe('file extensions', () => { + it('should allow .3gpp extension when explicitly allowed', () => { + const file = { name: 'test.3gpp', type: '', size: 1000 }; + expect( + isFileTypeAllowedForChannel(file, { + channelType: 'Channel::WebWidget', + }) + ).toBe(true); + }); + }); + + describe('Instagram special handling', () => { + it('should use Instagram rules when isInstagramChannel is true', () => { + const file = { name: 'test.png', type: 'image/png', size: 1000 }; + expect( + isFileTypeAllowedForChannel(file, { + channelType: 'Channel::WebWidget', + isInstagramChannel: true, + }) + ).toBe(true); + }); + + it('should use Instagram rules when conversationType is instagram_direct_message', () => { + const file = { name: 'test.png', type: 'image/png', size: 1000 }; + expect( + isFileTypeAllowedForChannel(file, { + channelType: 'Channel::WebWidget', + conversationType: 'instagram_direct_message', + }) + ).toBe(true); + }); + }); + + describe('disallowed file types', () => { + it('should reject executable files', () => { + const file = { + name: 'malware.exe', + type: 'application/x-msdownload', + size: 1000, + }; + expect( + isFileTypeAllowedForChannel(file, { + channelType: 'Channel::WebWidget', + }) + ).toBe(false); + }); + + it('should reject unsupported file types', () => { + const file = { + name: 'test.xyz', + type: 'application/x-unknown', + size: 1000, + }; + expect( + isFileTypeAllowedForChannel(file, { + channelType: 'Channel::WebWidget', + }) + ).toBe(false); + }); + }); + + describe('channel-specific rules', () => { + it('should allow WhatsApp-specific file types', () => { + const file = { name: 'test.pdf', type: 'application/pdf', size: 1000 }; + expect( + isFileTypeAllowedForChannel(file, { + channelType: 'Channel::Whatsapp', + }) + ).toBe(true); + }); + + it('should allow Twilio WhatsApp-specific file types', () => { + const file = { name: 'test.pdf', type: 'application/pdf', size: 1000 }; + expect( + isFileTypeAllowedForChannel(file, { + channelType: 'Channel::TwilioSms', + medium: 'whatsapp', + }) + ).toBe(true); + }); + }); + + describe('private note file types', () => { + it('should allow broader file types for private notes', () => { + const file = { + name: 'test.pdf', + type: 'application/pdf', + size: 1000, + }; + expect( + isFileTypeAllowedForChannel(file, { + channelType: 'Channel::Line', + isOnPrivateNote: true, + }) + ).toBe(true); + }); + + it('should allow CSV files in private notes', () => { + const file = { name: 'data.csv', type: 'text/csv', size: 1000 }; + expect( + isFileTypeAllowedForChannel(file, { + channelType: 'Channel::Line', + isOnPrivateNote: true, + }) + ).toBe(true); + }); + }); + }); });