fix: Block inline images in message signatures (#13772)

# Pull Request Template

## Description

This PR includes, block inline images in message signatures and prevent
auto signature insertion when editor is disabled.

- Strip inline base64 images from signature on save and show warning
message
- Add `INLINE_IMAGE_WARNING` translation key for signature inline image
removal notification
- Add disabled check to `addSignature()` to prevent signature insertion
when editor is disabled
- Add `isEditorDisabled` checks to signature toggle logic in
`toggleSignatureForDraft()`, `replaceText()`, and `clearMessage()`
- Remove unused `replaceText` from the codebase, which belongs to old
`textarea` editor

Fixes
https://linear.app/chatwoot/issue/CW-6588/the-browser-hangs-when-the-message-signature-contains-inline-image

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Loom video
https://www.loom.com/share/fb556b46a12a4308a737eed732d5ed73


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] 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
- [ ] 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-04-08 12:17:19 +05:30 committed by GitHub
parent e5107604a0
commit 699b12b1d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 83 additions and 36 deletions

View File

@ -313,7 +313,12 @@ const plugins = computed(() => {
const sendWithSignature = computed(() => { const sendWithSignature = computed(() => {
// this is considered the source of truth, we watch this property // this is considered the source of truth, we watch this property
// on change, we toggle the signature in the editor // on change, we toggle the signature in the editor
if (props.allowSignature && !props.isPrivate && props.channelType) { if (
props.allowSignature &&
!props.isPrivate &&
props.channelType &&
!props.disabled
) {
return fetchSignatureFlagFromUISettings(props.channelType); return fetchSignatureFlagFromUISettings(props.channelType);
} }
@ -436,6 +441,7 @@ function reloadState(content = props.modelValue) {
} }
function addSignature() { function addSignature() {
if (props.disabled) return;
let content = props.modelValue; let content = props.modelValue;
// see if the content is empty, if it is before appending the signature // see if the content is empty, if it is before appending the signature
// we need to add a paragraph node and move the cursor at the start of the editor // we need to add a paragraph node and move the cursor at the start of the editor
@ -454,6 +460,7 @@ function addSignature() {
} }
function removeSignature() { function removeSignature() {
if (props.disabled) return;
if (!props.signature) return; if (!props.signature) return;
let content = props.modelValue; let content = props.modelValue;
content = removeSignatureHelper( content = removeSignatureHelper(
@ -806,7 +813,7 @@ watch(
watch(sendWithSignature, newValue => { watch(sendWithSignature, newValue => {
// see if the allowSignature flag is true // see if the allowSignature flag is true
if (props.allowSignature) { if (props.allowSignature && !props.disabled) {
toggleSignatureInEditor(newValue); toggleSignatureInEditor(newValue);
} }
}); });

View File

@ -128,7 +128,6 @@ export default {
}, },
}, },
emits: [ emits: [
'replaceText',
'toggleInsertArticle', 'toggleInsertArticle',
'selectWhatsappTemplate', 'selectWhatsappTemplate',
'selectContentTemplate', 'selectContentTemplate',
@ -277,9 +276,6 @@ export default {
toggleMessageSignature() { toggleMessageSignature() {
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature); this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
}, },
replaceText(text) {
this.$emit('replaceText', text);
},
toggleInsertArticle() { toggleInsertArticle() {
this.$emit('toggleInsertArticle'); this.$emit('toggleInsertArticle');
}, },

View File

@ -27,7 +27,6 @@ import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import { import {
getMessageVariables, getMessageVariables,
getUndefinedVariablesInMessage, getUndefinedVariablesInMessage,
replaceVariablesInMessage,
} from '@chatwoot/utils'; } from '@chatwoot/utils';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue'; import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue'; import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
@ -636,10 +635,17 @@ export default {
return message; return message;
} }
// Even when editor is disabled (e.g. WhatsApp/API can't reply), we must
// still normalize stale signatures out of drafts when signature is off.
if (this.isEditorDisabled && this.sendWithSignature) {
return message;
}
const effectiveChannelType = getEffectiveChannelType( const effectiveChannelType = getEffectiveChannelType(
this.channelType, this.channelType,
this.inbox?.medium || '' this.inbox?.medium || ''
); );
return this.sendWithSignature return this.sendWithSignature
? appendSignature(message, this.messageSignature, effectiveChannelType) ? appendSignature(message, this.messageSignature, effectiveChannelType)
: removeSignature(message, this.messageSignature, effectiveChannelType); : removeSignature(message, this.messageSignature, effectiveChannelType);
@ -911,32 +917,6 @@ export default {
}); });
this.hideContentTemplatesModal(); this.hideContentTemplatesModal();
}, },
replaceText(message) {
if (this.sendWithSignature && !this.private) {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
const updatedMessage = replaceVariablesInMessage({
message,
variables: this.messageVariables,
});
setTimeout(() => {
useTrack(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
this.message = updatedMessage;
}, 100);
},
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) { setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
// Clear attachments when switching between private note and reply modes // Clear attachments when switching between private note and reply modes
// This is to prevent from breaking the upload rules // This is to prevent from breaking the upload rules
@ -1435,7 +1415,6 @@ export default {
:new-conversation-modal-active="newConversationModalActive" :new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal" @select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal" @select-content-template="openContentTemplateModal"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle" @toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply" @toggle-quoted-reply="toggleQuotedReply"
/> />

View File

@ -32,6 +32,25 @@ export function extractTextFromMarkdown(markdown) {
.trim(); // Trim any extra space .trim(); // Trim any extra space
} }
/**
* Removes inline base64 markdown images from signature content.
*
* @param {string} content
* @returns {{ sanitizedContent: string, hasInlineImages: boolean }}
*/
export function stripInlineBase64Images(content) {
if (!content || typeof content !== 'string') {
return { sanitizedContent: content || '', hasInlineImages: false };
}
const markdownInlineBase64ImageRegex =
/!\[[^\]]*]\(\s*data:image\/[a-zA-Z0-9.+-]+;base64,[^)]+\s*\)/gi;
const sanitizedContent = content.replace(markdownInlineBase64ImageRegex, '');
const hasInlineImages = sanitizedContent !== content;
return { sanitizedContent, hasInlineImages };
}
/** /**
* Strip unsupported markdown formatting based on channel capabilities. * Strip unsupported markdown formatting based on channel capabilities.
* Uses MARKDOWN_PATTERNS from editor constants. * Uses MARKDOWN_PATTERNS from editor constants.

View File

@ -15,6 +15,7 @@ import {
getMenuAnchor, getMenuAnchor,
calculateMenuPosition, calculateMenuPosition,
stripUnsupportedFormatting, stripUnsupportedFormatting,
stripInlineBase64Images,
} from '../editorHelper'; } from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor'; import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema'; import { EditorState } from '@chatwoot/prosemirror-schema';
@ -423,6 +424,36 @@ describe('extractTextFromMarkdown', () => {
}); });
}); });
describe('stripInlineBase64Images', () => {
it('removes markdown data:image base64 images and sets hasInlineImages', () => {
const content =
'Hello\n![x](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE)\nWorld';
const { sanitizedContent, hasInlineImages } =
stripInlineBase64Images(content);
expect(hasInlineImages).toBe(true);
expect(sanitizedContent).not.toContain('data:image/png;base64');
expect(sanitizedContent).toContain('Hello');
expect(sanitizedContent).toContain('World');
});
it('leaves hosted image markdown unchanged', () => {
const content = '![](https://example.com/logo.png)';
const { sanitizedContent, hasInlineImages } =
stripInlineBase64Images(content);
expect(hasInlineImages).toBe(false);
expect(sanitizedContent).toBe(content);
});
it('returns empty hasInlineImages for empty input', () => {
expect(stripInlineBase64Images('')).toEqual({
sanitizedContent: '',
hasInlineImages: false,
});
});
});
describe('insertAtCursor', () => { describe('insertAtCursor', () => {
it('should return undefined if editorView is not provided', () => { it('should return undefined if editorView is not provided', () => {
const result = insertAtCursor(undefined, schema.text('Hello'), 0); const result = insertAtCursor(undefined, schema.text('Hello'), 0);

View File

@ -68,7 +68,8 @@
"API_SUCCESS": "Signature saved successfully", "API_SUCCESS": "Signature saved successfully",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again", "IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature", "IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB" "IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB",
"INLINE_IMAGE_WARNING": "Pasted inline images were removed. Please use the image upload button to add images to your signature."
}, },
"MESSAGE_SIGNATURE": { "MESSAGE_SIGNATURE": {
"LABEL": "Message Signature", "LABEL": "Message Signature",

View File

@ -1,5 +1,8 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { stripInlineBase64Images } from 'dashboard/helper/editorHelper';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
@ -11,7 +14,9 @@ const props = defineProps({
}); });
const emit = defineEmits(['updateSignature']); const emit = defineEmits(['updateSignature']);
const signature = ref(props.messageSignature);
const { t } = useI18n();
const signature = ref(props.messageSignature ?? '');
watch( watch(
() => props.messageSignature ?? '', () => props.messageSignature ?? '',
newValue => { newValue => {
@ -20,6 +25,15 @@ watch(
); );
const updateSignature = () => { const updateSignature = () => {
const { sanitizedContent, hasInlineImages } = stripInlineBase64Images(
signature.value || ''
);
signature.value = sanitizedContent.trim();
if (hasInlineImages) {
useAlert(
t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.INLINE_IMAGE_WARNING')
);
}
emit('updateSignature', signature.value); emit('updateSignature', signature.value);
}; };
</script> </script>