From 58cec84b93cf121002f4389378d9340268915a22 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 12 Jan 2026 22:41:37 +0530 Subject: [PATCH 1/2] feat: sanitize html before assiging it to tempDiv (#13252) --- .../dashboard/helper/emailQuoteExtractor.js | 6 ++- .../dashboard/helper/quotedEmailHelper.js | 3 +- .../helper/specs/emailQuoteExtractor.spec.js | 54 +++++++++++++++++++ .../helper/specs/quotedEmailHelper.spec.js | 20 +++++++ 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/app/javascript/dashboard/helper/emailQuoteExtractor.js b/app/javascript/dashboard/helper/emailQuoteExtractor.js index 775dc4db4..f29d48cca 100644 --- a/app/javascript/dashboard/helper/emailQuoteExtractor.js +++ b/app/javascript/dashboard/helper/emailQuoteExtractor.js @@ -1,3 +1,5 @@ +import DOMPurify from 'dompurify'; + // Quote detection strategies const QUOTE_INDICATORS = [ '.gmail_quote_container', @@ -29,7 +31,7 @@ export class EmailQuoteExtractor { static extractQuotes(htmlContent) { // Create a temporary DOM element to parse HTML const tempDiv = document.createElement('div'); - tempDiv.innerHTML = htmlContent; + tempDiv.innerHTML = DOMPurify.sanitize(htmlContent); // Remove elements matching class selectors QUOTE_INDICATORS.forEach(selector => { @@ -56,7 +58,7 @@ export class EmailQuoteExtractor { */ static hasQuotes(htmlContent) { const tempDiv = document.createElement('div'); - tempDiv.innerHTML = htmlContent; + tempDiv.innerHTML = DOMPurify.sanitize(htmlContent); // Check for class-based quotes // eslint-disable-next-line no-restricted-syntax diff --git a/app/javascript/dashboard/helper/quotedEmailHelper.js b/app/javascript/dashboard/helper/quotedEmailHelper.js index b72fe8f50..9809b61bf 100644 --- a/app/javascript/dashboard/helper/quotedEmailHelper.js +++ b/app/javascript/dashboard/helper/quotedEmailHelper.js @@ -1,4 +1,5 @@ import { format, parseISO, isValid as isValidDate } from 'date-fns'; +import DOMPurify from 'dompurify'; /** * Extracts plain text from HTML content @@ -13,7 +14,7 @@ export const extractPlainTextFromHtml = html => { return html.replace(/<[^>]*>/g, ' '); } const tempDiv = document.createElement('div'); - tempDiv.innerHTML = html; + tempDiv.innerHTML = DOMPurify.sanitize(html); return tempDiv.textContent || tempDiv.innerText || ''; }; diff --git a/app/javascript/dashboard/helper/specs/emailQuoteExtractor.spec.js b/app/javascript/dashboard/helper/specs/emailQuoteExtractor.spec.js index 7bd2aaa51..20b50581b 100644 --- a/app/javascript/dashboard/helper/specs/emailQuoteExtractor.spec.js +++ b/app/javascript/dashboard/helper/specs/emailQuoteExtractor.spec.js @@ -96,4 +96,58 @@ describe('EmailQuoteExtractor', () => { it('detects quotes for trailing blockquotes even when signatures follow text', () => { expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true); }); + + describe('HTML sanitization', () => { + it('removes onerror handlers from img tags in extractQuotes', () => { + const maliciousHtml = '

Hello

'; + const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml); + + expect(cleanedHtml).not.toContain('onerror'); + expect(cleanedHtml).toContain('

Hello

'); + }); + + it('removes onerror handlers from img tags in hasQuotes', () => { + const maliciousHtml = '

Hello

'; + // Should not throw and should safely check for quotes + const result = EmailQuoteExtractor.hasQuotes(maliciousHtml); + expect(result).toBe(false); + }); + + it('removes script tags in extractQuotes', () => { + const maliciousHtml = + '

Content

More

'; + const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml); + + expect(cleanedHtml).not.toContain('Content

'); + expect(cleanedHtml).toContain('

More

'); + }); + + it('removes onclick handlers in extractQuotes', () => { + const maliciousHtml = '

Click me

'; + const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml); + + expect(cleanedHtml).not.toContain('onclick'); + expect(cleanedHtml).toContain('Click me'); + }); + + it('removes javascript: URLs in extractQuotes', () => { + const maliciousHtml = 'Link'; + const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml); + + // eslint-disable-next-line no-script-url + expect(cleanedHtml).not.toContain('javascript:'); + expect(cleanedHtml).toContain('Link'); + }); + + it('removes encoded payloads with event handlers in extractQuotes', () => { + const maliciousHtml = + ''; + const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml); + + expect(cleanedHtml).not.toContain('onerror'); + expect(cleanedHtml).not.toContain('eval'); + }); + }); }); diff --git a/app/javascript/dashboard/helper/specs/quotedEmailHelper.spec.js b/app/javascript/dashboard/helper/specs/quotedEmailHelper.spec.js index 801989124..bc38d09a8 100644 --- a/app/javascript/dashboard/helper/specs/quotedEmailHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/quotedEmailHelper.spec.js @@ -33,6 +33,26 @@ describe('quotedEmailHelper', () => { expect(result).toContain('Line 1'); expect(result).toContain('Line 2'); }); + + it('sanitizes onerror handlers from img tags', () => { + const html = '

Hello

'; + const result = extractPlainTextFromHtml(html); + expect(result).toBe('Hello'); + }); + + it('sanitizes script tags', () => { + const html = '

Safe

Content

'; + const result = extractPlainTextFromHtml(html); + expect(result).toContain('Safe'); + expect(result).toContain('Content'); + expect(result).not.toContain('alert'); + }); + + it('sanitizes onclick handlers', () => { + const html = '

Click me

'; + const result = extractPlainTextFromHtml(html); + expect(result).toBe('Click me'); + }); }); describe('getEmailSenderName', () => { From ff68c3a74f72a27312d3a221901a3d4af057ba95 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 12 Jan 2026 09:14:25 -0800 Subject: [PATCH 2/2] Bump version to 4.9.2 --- VERSION_CW | 2 +- config/app.yml | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/VERSION_CW b/VERSION_CW index 5b341fd79..dad10c76d 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.9.1 +4.9.2 diff --git a/config/app.yml b/config/app.yml index 0c7a390f0..65d2e7886 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '4.9.1' + version: '4.9.2' development: <<: *shared diff --git a/package.json b/package.json index 87f44528a..e530f9cb8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "4.9.1", + "version": "4.9.2", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}",