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 <muhsinkeramam@gmail.com>
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user