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,
|
removeSignature,
|
||||||
getEffectiveChannelType,
|
getEffectiveChannelType,
|
||||||
} from 'dashboard/helper/editorHelper';
|
} from 'dashboard/helper/editorHelper';
|
||||||
|
import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
|
||||||
|
|
||||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||||
@@ -637,6 +638,25 @@ export default {
|
|||||||
// Filter valid files (non-zero size)
|
// Filter valid files (non-zero size)
|
||||||
Array.from(e.clipboardData.files)
|
Array.from(e.clipboardData.files)
|
||||||
.filter(file => file.size > 0)
|
.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 => {
|
.forEach(file => {
|
||||||
const { name, type, size } = file;
|
const { name, type, size } = file;
|
||||||
this.onFileUpload({ name, type, size, file });
|
this.onFileUpload({ name, type, size, file });
|
||||||
|
|||||||
@@ -247,6 +247,7 @@
|
|||||||
"SUCCESS_DELETE_CONVERSATION": "Conversation deleted successfully",
|
"SUCCESS_DELETE_CONVERSATION": "Conversation deleted successfully",
|
||||||
"FAIL_DELETE_CONVERSATION": "Couldn't delete conversation! Try again",
|
"FAIL_DELETE_CONVERSATION": "Couldn't delete conversation! Try again",
|
||||||
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit",
|
"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",
|
"MESSAGE_ERROR": "Unable to send this message, please try again later",
|
||||||
"SENT_BY": "Sent by:",
|
"SENT_BY": "Sent by:",
|
||||||
"BOT": "Bot",
|
"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 DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE = 40;
|
||||||
|
|
||||||
export const formatBytes = (bytes, decimals = 2) => {
|
export const formatBytes = (bytes, decimals = 2) => {
|
||||||
@@ -31,3 +35,55 @@ export const resolveMaximumFileUploadSize = value => {
|
|||||||
|
|
||||||
return parsedValue;
|
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,
|
fileSizeInMegaBytes,
|
||||||
checkFileSizeLimit,
|
checkFileSizeLimit,
|
||||||
resolveMaximumFileUploadSize,
|
resolveMaximumFileUploadSize,
|
||||||
|
isFileTypeAllowedForChannel,
|
||||||
} from '../FileHelper';
|
} from '../FileHelper';
|
||||||
|
|
||||||
describe('#File Helpers', () => {
|
describe('#File Helpers', () => {
|
||||||
@@ -61,4 +62,187 @@ describe('#File Helpers', () => {
|
|||||||
expect(resolveMaximumFileUploadSize(75)).toBe(75);
|
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