diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8ecf27b..5b34b97 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,7 +7,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - node: ['16.x', '20.x', '23.x'] + node: ['20.x', '23.x'] os: ['ubuntu-latest'] steps: 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", 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..2799528 100644 --- a/src/string.ts +++ b/src/string.ts @@ -3,7 +3,6 @@ * @param {string | number} candidate - The string boolean value to be parsed * @return {boolean} - The parsed boolean value */ - export function parseBoolean(candidate: string | number) { try { // lowercase the string, so TRUE becomes true @@ -16,3 +15,65 @@ 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 | 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 + * 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 ''; + + 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 + // \/? - optional self-closing slash before > + // ) - end lookbehind + // > - matches '>' + .replace(/(?]*)?\/?)>/g, '>') + ); +} diff --git a/test/string.test.ts b/test/string.test.ts index 9a85eb2..a55e74c 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,156 @@ describe('#parseBoolean', () => { expect(parseBoolean(undefined)).toBe(false); }); }); + +describe('#sanitizeTextForRender', () => { + it('should handle null and undefined values', () => { + expect(sanitizeTextForRender(null)).toBe(''); + expect(sanitizeTextForRender(undefined)).toBe(''); + 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); + }); + + 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( + '