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:
Sivin Varghese
2026-01-14 14:37:46 +05:30
committed by GitHub
parent daaa18b18a
commit 4e0b091ef8
4 changed files with 261 additions and 0 deletions

View File

@@ -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 });

View 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",

View File

@@ -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;
});
};

View File

@@ -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);
});
});
});
});