diff --git a/src/components/FilePreview/FilePreview.tsx b/src/components/FilePreview/FilePreview.tsx index a9862620..8ace02f9 100644 --- a/src/components/FilePreview/FilePreview.tsx +++ b/src/components/FilePreview/FilePreview.tsx @@ -126,9 +126,7 @@ const FilePreview = ({ if (isHtmlFile(file)) { let htmlContent = content; if (htmlContent.includes('<') || htmlContent.includes('"')) { - const div = document.createElement('div'); - div.innerHTML = htmlContent; - htmlContent = div.textContent || div.innerText || htmlContent; + htmlContent = stripHTML(htmlContent) || htmlContent; } htmlContent = stripDocumentAttachmentTags(htmlContent); return htmlContent; diff --git a/src/components/MediaWidget/MediaItemWidget.tsx b/src/components/MediaWidget/MediaItemWidget.tsx index 51eeeb44..0a23450f 100644 --- a/src/components/MediaWidget/MediaItemWidget.tsx +++ b/src/components/MediaWidget/MediaItemWidget.tsx @@ -11,6 +11,7 @@ import React, { import { getResourceUrl } from '../../helpers/media'; import { withLinksOpenInNewTab, + stripHTML, stripDocumentAttachmentTags, isAssetOnlyDocumentAttachment, getDocumentAttachmentAssetUrl, @@ -433,9 +434,7 @@ export const RenderMediaItem = memo(function RenderMediaItem({ htmlContent.includes(' { + it('strips onerror from partner XSS img payload', () => { + const payload = + ''; + const result = sanitizeMsg(payload); + expect(result).toBe(''); + expect(result).not.toMatch(/onerror/i); + }); + + it('strips svg onload handlers', () => { + const result = sanitizeMsg(''); + expect(result).not.toMatch(/onload/i); + }); +}); describe('renderMsg', () => { it('should render message with reasoning and output tag adjacent correctly', () => { @@ -11,6 +31,13 @@ describe('renderMsg', () => { '
Reasoning...L\'utente vuole che io elenchi i parametri dell\'ALBERO ESISTENTE.
\n\n\n\n

Recupero tutti i 7 parametri dell\'ALBERO ESISTENTE...

' ); }); + + it('strips onerror from XSS img payload in rendered output', () => { + const payload = + ''; + const result = renderMsg(payload, false, 'Reasoning...', false); + expect(result.text).not.toMatch(/onerror/i); + }); }); describe('stripAttachmentTags', () => { diff --git a/src/helpers/message.ts b/src/helpers/message.ts index 39cbb270..27fc60b8 100644 --- a/src/helpers/message.ts +++ b/src/helpers/message.ts @@ -85,6 +85,9 @@ export const truncateMessage = (message: string) => { return truncatedMessage; }; +export const sanitizeMsg = (msg: string) => + DOMPurify.sanitize(msg, { ADD_ATTR: ['target'] }); + export const renderMsg = ( text: string, useMathFormatting = false, @@ -196,9 +199,6 @@ export const renderMsg = ( return { text: finalText }; } catch (e) { console.error('Error rendering message:', e); - return { text }; + return { text: sanitizeMsg(text) }; } }; - -export const sanitizeMsg = (msg: string) => - DOMPurify.sanitize(msg, { ADD_ATTR: ['target'] }); diff --git a/src/helpers/utils.test.ts b/src/helpers/utils.test.ts index 21152801..62ea4284 100644 --- a/src/helpers/utils.test.ts +++ b/src/helpers/utils.test.ts @@ -1,6 +1,7 @@ import { difference, stripEmojis, + stripHTML, stripMarkdown, stripOutputTags, escapeHTML, @@ -254,6 +255,54 @@ describe('utils/attachment helpers', () => { }); }); +describe('utils/stripHTML', () => { + it('strips tags and returns text content', () => { + expect(stripHTML('

Hello world

')).toBe( + 'Hello world' + ); + }); + + it('does not retain onerror from partner XSS payload', () => { + const payload = + ''; + const result = stripHTML(payload); + expect(result).toBe(''); + expect(result).not.toMatch(/onerror/i); + // Re-parse result: must not introduce an executable handler attribute + const reparsed = new DOMParser().parseFromString(result, 'text/html'); + expect(reparsed.querySelector('[onerror]')).toBeNull(); + expect(reparsed.querySelector('img')).toBeNull(); + }); + + it('strips svg onload payloads without leaving handlers', () => { + const payload = 'x'; + const result = stripHTML(payload); + expect(result).toBe('x'); + expect(result).not.toMatch(/onload/i); + }); + + it('decodes HTML entities without executing nested markup', () => { + const encoded = + '<img src="x" onerror="alert(1)">hello</img>'; + const result = stripHTML(encoded); + expect(result).toContain('hello'); + // Entity-decoded text may contain the string "onerror" as text; ensure no live element + const reparsed = new DOMParser().parseFromString( + `
${result.replace(/`, + 'text/html' + ); + expect(reparsed.querySelector('[onerror]')).toBeNull(); + }); + + it('handles nested tags', () => { + expect(stripHTML('
ab
')).toBe('ab'); + }); + + it('returns empty string for empty input', () => { + expect(stripHTML('')).toBe(''); + }); +}); + describe('utils/parsing combined', () => { it('should remove output tag from real message', () => { const result = escapeHTML( diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts index 7195ff29..41de0ac4 100644 --- a/src/helpers/utils.ts +++ b/src/helpers/utils.ts @@ -363,10 +363,23 @@ export const stripReasoningTags = (text: string) => { return strippedText; }; +/** + * Strip HTML tags / decode entities without executing scripts or event handlers. + * Uses an inert DOMParser document (no resource loads / onerror), unlike live innerHTML. + */ export const stripHTML = (text: string) => { - const el = document.createElement('div'); - el.innerHTML = text; - return el.textContent || ''; + if (typeof DOMParser !== 'undefined') { + try { + return ( + new DOMParser().parseFromString(text, 'text/html').body.textContent || + '' + ); + } catch { + // fall through + } + } + // Non-DOM / parse failure: strip tags only (does not decode entities) + return text.replace(/<[^>]*>/g, ''); }; /** Ensures all tags in HTML open in a new tab (target="_blank" rel="noopener noreferrer"). */