From 8e5a0a9f47a814fc5ba8e73be55e037a78aace9b Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 4 Sep 2025 11:28:04 +0530 Subject: [PATCH 1/8] feat: add sanitizeTextForRender method --- src/index.ts | 3 +- src/string.ts | 61 +++++++++++++++++++++++++++++++ test/string.test.ts | 89 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 151 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 08294f5..ca14ac0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ import { toURL, isSameHost, isValidDomain } from './url'; import { getRecipients } from './email'; -import { parseBoolean } from './string'; +import { parseBoolean, sanitizeTextForRender } from './string'; import { sortAsc, quantile, @@ -62,6 +62,7 @@ export { parseBoolean, quantile, replaceVariablesInMessage, + sanitizeTextForRender, sortAsc, splitName, toURL, diff --git a/src/string.ts b/src/string.ts index 1971547..83ca937 100644 --- a/src/string.ts +++ b/src/string.ts @@ -16,3 +16,64 @@ export function parseBoolean(candidate: string | number) { return false; } } + +/** + * Sanitizes text for safe HTML rendering by escaping potentially dangerous characters + * while preserving valid HTML tags. + * + * This function performs the following transformations: + * - Converts newline characters (\n) to HTML line breaks (
) + * - Escapes stray '<' characters that are not part of valid HTML tags (e.g., "x < 5" → "x < 5") + * - Escapes stray '>' characters that are not part of valid HTML tags (e.g., "x > 5" → "x > 5") + * - Preserves valid HTML tags and their attributes (e.g.,
, ,

) + * + * LIMITATIONS: This regex-based approach has known limitations: + * - Cannot properly handle '>' characters inside HTML attributes (e.g.,
may not work correctly) + * - Complex nested quotes or edge cases may not be handled perfectly + * - For more complex HTML sanitization needs, consider using a proper HTML parser + * + * @param {string} text - The text to sanitize. Can be null or undefined. + * @returns {string} The sanitized text safe for HTML rendering, or the original value if null/undefined. + * + * @example + * sanitizeTextForRender('Hello\nWorld') // 'Hello
World' + * sanitizeTextForRender('if x < 5') // 'if x < 5' + * sanitizeTextForRender('
Hello
') // '
Hello
' + * sanitizeTextForRender('Price < $100 Sale!') // 'Price < $100 Sale!' + */ +export function sanitizeTextForRender(text: string | null | undefined) { + if (!text) return text; + + return ( + text + .replace(/\n/g, '
') + + // Escape < that doesn't start a valid HTML tag + // Regex breakdown: + // < - matches '<' + // (?! - negative lookahead (not followed by) + // \/? - optional forward slash for closing tags + // \w+ - one or more word characters (tag name) + // (?: - non-capturing group for attributes + // \s+ - whitespace before attributes + // [^>]* - any characters except '>' (attribute content) + // )? - attributes are optional + // \/?> - optional self-closing slash, then '>' + // ) - end lookahead + .replace(/<(?!\/?\w+(?:\s+[^>]*)?\/?>)/g, '<') + + // Escape > that isn't part of an HTML tag + // Regex breakdown: + // (?]* - any characters except '>' (attribute content) + // )? - attributes are optional + // ) - end lookbehind + // > - matches '>' + .replace(/(?]*)?)>/g, '>') + ); +} diff --git a/test/string.test.ts b/test/string.test.ts index 9a85eb2..956d777 100644 --- a/test/string.test.ts +++ b/test/string.test.ts @@ -1,4 +1,4 @@ -import { parseBoolean } from '../src'; +import { parseBoolean, sanitizeTextForRender } from '../src'; describe('#parseBoolean', () => { test('returns true for input "true"', () => { @@ -37,3 +37,90 @@ describe('#parseBoolean', () => { expect(parseBoolean(undefined)).toBe(false); }); }); + +describe('#sanitizeTextForRender', () => { + it('should handle null and undefined values', () => { + expect(sanitizeTextForRender(null)).toBe(null); + expect(sanitizeTextForRender(undefined)).toBe(undefined); + expect(sanitizeTextForRender('')).toBe(''); + }); + + it('should convert newlines to
tags', () => { + expect(sanitizeTextForRender('Line 1\nLine 2')).toBe('Line 1
Line 2'); + expect(sanitizeTextForRender('Multiple\n\nNewlines')).toBe('Multiple

Newlines'); + }); + + it('should escape stray < characters', () => { + expect(sanitizeTextForRender('if x < 5')).toBe('if x < 5'); + expect(sanitizeTextForRender('< this is not a tag')).toBe('< this is not a tag'); + expect(sanitizeTextForRender('price < $100')).toBe('price < $100'); + }); + + it('should escape stray > characters', () => { + expect(sanitizeTextForRender('if x > 5')).toBe('if x > 5'); + expect(sanitizeTextForRender('this is not a tag >')).toBe('this is not a tag >'); + expect(sanitizeTextForRender('score > 90%')).toBe('score > 90%'); + }); + + it('should escape both stray < and > characters', () => { + expect(sanitizeTextForRender('5 < x < 10')).toBe('5 < x < 10'); + expect(sanitizeTextForRender('x > 5 && y < 10')).toBe('x > 5 && y < 10'); + }); + + it('should preserve valid HTML tags', () => { + expect(sanitizeTextForRender('
Hello
')).toBe('
Hello
'); + expect(sanitizeTextForRender('World')).toBe('World'); + expect(sanitizeTextForRender('
')).toBe('
'); + expect(sanitizeTextForRender('')).toBe(''); + }); + + it('should preserve nested HTML tags', () => { + expect(sanitizeTextForRender('
Nested
')).toBe('
Nested
'); + expect(sanitizeTextForRender('
  • Item 1
  • Item 2
')) + .toBe('
  • Item 1
  • Item 2
'); + }); + + it('should handle mixed content with valid tags and stray characters', () => { + expect(sanitizeTextForRender('Price < $100 on sale')) + .toBe('Price < $100 on sale'); + expect(sanitizeTextForRender('

x > 5

and y < 10')) + .toBe('

x > 5

and y < 10'); + }); + + it('should handle edge cases with malformed HTML-like content', () => { + expect(sanitizeTextForRender('<>')).toBe('<>'); + expect(sanitizeTextForRender('')).toBe('not a tag>'); + }); + + it('should handle email addresses and URLs with angle brackets', () => { + expect(sanitizeTextForRender('Contact: ')) + .toBe('Contact: <user@example.com>'); + expect(sanitizeTextForRender('Email me at < user@example.com >')) + .toBe('Email me at < user@example.com >'); + }); + + it('should handle mathematical expressions', () => { + expect(sanitizeTextForRender('if (x < y && y > z)')).toBe('if (x < y && y > z)'); + expect(sanitizeTextForRender('array[i] < array[j]')).toBe('array[i] < array[j]'); + }); + + it('should handle HTML entities within valid tags', () => { + expect(sanitizeTextForRender('
<escaped>
')) + .toBe('
<escaped>
'); + expect(sanitizeTextForRender('already & escaped')) + .toBe('already & escaped'); + }); + + it('should handle complex real-world email content', () => { + const emailContent = `Hello,\n\nThe price is < $50 for items where quantity > 10.\n

Best regards,

\nSales Team`; + const expected = `Hello,

The price is < $50 for items where quantity > 10.

Best regards,


Sales Team`; + expect(sanitizeTextForRender(emailContent)).toBe(expected); + }); + + it('should handle quoted email content', () => { + const quoted = `Original message:\n> User wrote: x < 5\n
Previous reply
`; + const expected = `Original message:
> User wrote: x < 5
Previous reply
`; + expect(sanitizeTextForRender(quoted)).toBe(expected); + }); +}); From 31f091589e35df5556c2addee0b187633b6c93d6 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 4 Sep 2025 11:28:46 +0530 Subject: [PATCH 2/8] chore: bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 38b0df4..310b99f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/utils", - "version": "0.0.50", + "version": "0.0.51", "description": "Chatwoot utils", "private": false, "license": "MIT", From 27884996089899db0183a1527a35adbd19450bf9 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 4 Sep 2025 11:30:34 +0530 Subject: [PATCH 3/8] style: run lint --- test/string.test.ts | 71 ++++++++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/test/string.test.ts b/test/string.test.ts index 956d777..b29b7c2 100644 --- a/test/string.test.ts +++ b/test/string.test.ts @@ -47,44 +47,61 @@ describe('#sanitizeTextForRender', () => { it('should convert newlines to
tags', () => { expect(sanitizeTextForRender('Line 1\nLine 2')).toBe('Line 1
Line 2'); - expect(sanitizeTextForRender('Multiple\n\nNewlines')).toBe('Multiple

Newlines'); + expect(sanitizeTextForRender('Multiple\n\nNewlines')).toBe( + 'Multiple

Newlines' + ); }); it('should escape stray < characters', () => { expect(sanitizeTextForRender('if x < 5')).toBe('if x < 5'); - expect(sanitizeTextForRender('< this is not a tag')).toBe('< this is not a tag'); + expect(sanitizeTextForRender('< this is not a tag')).toBe( + '< this is not a tag' + ); expect(sanitizeTextForRender('price < $100')).toBe('price < $100'); }); it('should escape stray > characters', () => { expect(sanitizeTextForRender('if x > 5')).toBe('if x > 5'); - expect(sanitizeTextForRender('this is not a tag >')).toBe('this is not a tag >'); + expect(sanitizeTextForRender('this is not a tag >')).toBe( + 'this is not a tag >' + ); expect(sanitizeTextForRender('score > 90%')).toBe('score > 90%'); }); it('should escape both stray < and > characters', () => { expect(sanitizeTextForRender('5 < x < 10')).toBe('5 < x < 10'); - expect(sanitizeTextForRender('x > 5 && y < 10')).toBe('x > 5 && y < 10'); + expect(sanitizeTextForRender('x > 5 && y < 10')).toBe( + 'x > 5 && y < 10' + ); }); it('should preserve valid HTML tags', () => { expect(sanitizeTextForRender('
Hello
')).toBe('
Hello
'); - expect(sanitizeTextForRender('World')).toBe('World'); + expect(sanitizeTextForRender('World')).toBe( + 'World' + ); expect(sanitizeTextForRender('
')).toBe('
'); - expect(sanitizeTextForRender('')).toBe(''); + expect(sanitizeTextForRender('')).toBe( + '' + ); }); it('should preserve nested HTML tags', () => { - expect(sanitizeTextForRender('
Nested
')).toBe('
Nested
'); - expect(sanitizeTextForRender('
  • Item 1
  • Item 2
')) - .toBe('
  • Item 1
  • Item 2
'); + expect(sanitizeTextForRender('
Nested
')).toBe( + '
Nested
' + ); + expect( + sanitizeTextForRender('
  • Item 1
  • Item 2
') + ).toBe('
  • Item 1
  • Item 2
'); }); it('should handle mixed content with valid tags and stray characters', () => { - expect(sanitizeTextForRender('Price < $100 on sale')) - .toBe('Price < $100 on sale'); - expect(sanitizeTextForRender('

x > 5

and y < 10')) - .toBe('

x > 5

and y < 10'); + expect(sanitizeTextForRender('Price < $100 on sale')).toBe( + 'Price < $100 on sale' + ); + expect(sanitizeTextForRender('

x > 5

and y < 10')).toBe( + '

x > 5

and y < 10' + ); }); it('should handle edge cases with malformed HTML-like content', () => { @@ -94,22 +111,30 @@ describe('#sanitizeTextForRender', () => { }); it('should handle email addresses and URLs with angle brackets', () => { - expect(sanitizeTextForRender('Contact: ')) - .toBe('Contact: <user@example.com>'); - expect(sanitizeTextForRender('Email me at < user@example.com >')) - .toBe('Email me at < user@example.com >'); + expect(sanitizeTextForRender('Contact: ')).toBe( + 'Contact: <user@example.com>' + ); + expect(sanitizeTextForRender('Email me at < user@example.com >')).toBe( + 'Email me at < user@example.com >' + ); }); it('should handle mathematical expressions', () => { - expect(sanitizeTextForRender('if (x < y && y > z)')).toBe('if (x < y && y > z)'); - expect(sanitizeTextForRender('array[i] < array[j]')).toBe('array[i] < array[j]'); + expect(sanitizeTextForRender('if (x < y && y > z)')).toBe( + 'if (x < y && y > z)' + ); + expect(sanitizeTextForRender('array[i] < array[j]')).toBe( + 'array[i] < array[j]' + ); }); it('should handle HTML entities within valid tags', () => { - expect(sanitizeTextForRender('
<escaped>
')) - .toBe('
<escaped>
'); - expect(sanitizeTextForRender('already & escaped')) - .toBe('already & escaped'); + expect(sanitizeTextForRender('
<escaped>
')).toBe( + '
<escaped>
' + ); + expect(sanitizeTextForRender('already & escaped')).toBe( + 'already & escaped' + ); }); it('should handle complex real-world email content', () => { From 4318c0d419c0d6df6176b95444679b61372cbaf0 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 4 Sep 2025 11:34:53 +0530 Subject: [PATCH 4/8] fix: self closing tags getting escaped --- src/string.ts | 7 ++++--- test/string.test.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/string.ts b/src/string.ts index 83ca937..d10ad18 100644 --- a/src/string.ts +++ b/src/string.ts @@ -32,7 +32,7 @@ export function parseBoolean(candidate: string | number) { * - Complex nested quotes or edge cases may not be handled perfectly * - For more complex HTML sanitization needs, consider using a proper HTML parser * - * @param {string} text - The text to sanitize. Can be null or undefined. + * @param {string | null | undefined} text - The text to sanitize. Can be null or undefined. * @returns {string} The sanitized text safe for HTML rendering, or the original value if null/undefined. * * @example @@ -42,7 +42,7 @@ export function parseBoolean(candidate: string | number) { * sanitizeTextForRender('Price < $100 Sale!') // 'Price < $100 Sale!' */ export function sanitizeTextForRender(text: string | null | undefined) { - if (!text) return text; + if (!text) return ''; return ( text @@ -72,8 +72,9 @@ export function sanitizeTextForRender(text: string | null | undefined) { // \s+ - whitespace before attributes // [^>]* - any characters except '>' (attribute content) // )? - attributes are optional + // \/? - optional self-closing slash before > // ) - end lookbehind // > - matches '>' - .replace(/(?]*)?)>/g, '>') + .replace(/(?]*)?\/?)>/g, '>') ); } diff --git a/test/string.test.ts b/test/string.test.ts index b29b7c2..b94b58d 100644 --- a/test/string.test.ts +++ b/test/string.test.ts @@ -148,4 +148,19 @@ describe('#sanitizeTextForRender', () => { const expected = `Original message:
> User wrote: x < 5
Previous reply
`; expect(sanitizeTextForRender(quoted)).toBe(expected); }); + + it('should handle self-closing tags correctly', () => { + expect(sanitizeTextForRender('
')).toBe('
'); + expect(sanitizeTextForRender('')).toBe(''); + expect(sanitizeTextForRender('')).toBe(''); + expect(sanitizeTextForRender('
')).toBe('
'); + expect(sanitizeTextForRender('Text before
text after')).toBe('Text before
text after'); + expect(sanitizeTextForRender('')).toBe(''); + }); + + it('should handle complex URLs in attributes', () => { + expect(sanitizeTextForRender('')).toBe(''); + expect(sanitizeTextForRender('Profile')).toBe('Profile'); + expect(sanitizeTextForRender('