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:
@@ -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