feat: exports new sanitizeUserDataForEmail function (#13029)

### What?

Adds a new `sanitizeUserDataForEmail` function, exported from
`payload/shared`.
This function sanitizes user data passed to email templates to prevent
injection of HTML, executable code, or other malicious content.

### Why?

In the existing `email` example, we directly insert `user.name` into the
generated email content. Similarly, the `newsletter` collection uses
`doc.name` directly in the email content. A security report identified
this as a potential vulnerability that could be exploited and used to
inject executable or malicious code.

Although this issue does not originate from Payload core, developers
using our examples may unknowingly introduce this vulnerability into
their own codebases.

### How?

Introduces the pre-built `sanitizeUserDataForEmail` function and updates
relevant email examples to use it.

**Fixes `CMS2-1225-14`**
This commit is contained in:
Jessica Rynkar
2025-07-08 12:47:34 +01:00
committed by GitHub
parent 4c25357831
commit 9f1bff57c1
5 changed files with 238 additions and 2 deletions

View File

@@ -1,4 +1,5 @@
import type { CollectionConfig } from 'payload'
import { sanitizeUserDataForEmail } from 'payload/shared'
import { generateEmailHTML } from '../email/generateEmailHTML'
@@ -26,7 +27,7 @@ export const Newsletter: CollectionConfig = {
.sendEmail({
from: 'sender@example.com',
html: await generateEmailHTML({
content: `<p>${doc.name ? `Hi ${doc.name}!` : 'Hi!'} We'll be in touch soon...</p>`,
content: `<p>${doc.name ? `Hi ${sanitizeUserDataForEmail(doc.name)}!` : 'Hi!'} We'll be in touch soon...</p>`,
headline: 'Welcome to the newsletter!',
}),
subject: 'Thanks for signing up!',

View File

@@ -1,5 +1,7 @@
import { generateEmailHTML } from './generateEmailHTML'
import { sanitizeUserDataForEmail } from 'payload/shared'
type User = {
email: string
name?: string
@@ -16,7 +18,7 @@ export const generateVerificationEmail = async (
const { token, user } = args
return generateEmailHTML({
content: `<p>Hi${user.name ? ' ' + user.name : ''}! Validate your account by clicking the button below.</p>`,
content: `<p>Hi${user.name ? ' ' + sanitizeUserDataForEmail(user.name) : ''}! Validate your account by clicking the button below.</p>`,
cta: {
buttonLabel: 'Verify',
url: `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/verify?token=${token}&email=${user.email}`,

View File

@@ -112,6 +112,8 @@ export {
export { reduceFieldsToValues } from '../utilities/reduceFieldsToValues.js'
export { sanitizeUserDataForEmail } from '../utilities/sanitizeUserDataForEmail.js'
export { setsAreEqual } from '../utilities/setsAreEqual.js'
export { toKebabCase } from '../utilities/toKebabCase.js'

View File

@@ -0,0 +1,156 @@
import { sanitizeUserDataForEmail } from './sanitizeUserDataForEmail'
describe('sanitizeUserDataForEmail', () => {
it('should remove anchor tags', () => {
const input = '<a href="https://example.com">Click me</a>'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('Click me')
})
it('should remove script tags', () => {
const unsanitizedData = '<script>alert</script>'
const sanitizedData = sanitizeUserDataForEmail(unsanitizedData)
expect(sanitizedData).toBe('alert')
})
it('should remove mixed-case script tags', () => {
const input = '<ScRipT>alert(1)</sCrIpT>'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('alert1')
})
it('should remove embedded base64-encoded scripts', () => {
const input = '<img src="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('')
})
it('should remove iframe elements', () => {
const input = '<iframe src="malicious.com"></iframe>Frame'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('Frame')
})
it('should remove javascript: links in attributes', () => {
const input = '<a href="javascript:alert(1)">click</a>'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('click')
})
it('should remove mismatched script input', () => {
const input = '<script>console.log("test")'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('console.log\"test\"')
})
it('should remove encoded scripts via HTML entities', () => {
const input = '&#x3C;script&#x3E;alert(1)&#x3C;/script&#x3E;'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('alert1')
})
it('should remove template injection syntax', () => {
const input = '{{7*7}}'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('77')
})
it('should remove invisible zero-width characters', () => {
const input = 'a\u200Bler\u200Bt("XSS")'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('alert\"XSS\"')
})
it('should remove CSS expressions within style attributes', () => {
const input = '<div style="width: expression(alert(\'XSS\'));">Hello</div>'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('Hello')
})
it('should not render SVG with onload event', () => {
const input = '<svg onload="alert(\'XSS\')">Graphic</svg>'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('Graphic')
})
it('should not allow backtick-based patterns', () => {
const input = '`alert("XSS")`'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('alert\"XSS\"')
})
it('should preserve allowed punctuation', () => {
const input = `Hello "world" - it's safe!`
const result = sanitizeUserDataForEmail(input)
expect(result).toBe(`Hello "world" - it's safe!`)
})
it('should return empty string for non-string input', () => {
expect(sanitizeUserDataForEmail(null)).toBe('')
expect(sanitizeUserDataForEmail(undefined)).toBe('')
expect(sanitizeUserDataForEmail(123)).toBe('')
expect(sanitizeUserDataForEmail({})).toBe('')
})
it('should return empty string for an empty string', () => {
expect(sanitizeUserDataForEmail('')).toBe('')
})
it('should collapse excessive whitespace', () => {
const input = 'This is \n\n a test'
expect(sanitizeUserDataForEmail(input)).toBe('This is a test')
})
it('should truncate to maxLength characters', () => {
const input = 'a'.repeat(200)
const result = sanitizeUserDataForEmail(input, 50)
expect(result.length).toBe(50)
})
it('should remove characters outside allowed punctuation', () => {
const input = 'Hello @#$%^*()_+=[]{}|\\~`'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('Hello')
})
it('should sanitize syntax in regex-like input', () => {
const input = '(?=XSS)(?:abc)'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('XSSabc')
})
it('should handle string of only control characters', () => {
const input = '\x01\x02\x03\x04'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('')
})
it('should sanitize complex script attempt with mixed encoding', () => {
const input = '&#x3C;script&#x3E;alert(String.fromCharCode(88,83,83))&#x3C;/script&#x3E;'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('alertString.fromCharCode88,83,83')
})
it('should handle deeply nested HTML tags correctly', () => {
const input = `<div><section><article><p>Hello <strong>world <em>from <span>deep <a href="#">tags</a></span></em></strong></p></article></section></div>`
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('Hello world from deep tags')
})
it('should preserve accented Spanish characters', () => {
const input = '¡Hola! ¿Cómo estás? ÁÉÍÓÚ ÜÑ ñáéíóú ü'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('¡Hola! ¿Cómo estás? ÁÉÍÓÚ ÜÑ ñáéíóú ü')
})
it('should preserve Arabic characters with diacritics', () => {
const input = 'مَرْحَبًا بِكَ فِي الْمَوْقِعِ'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('مَرْحَبًا بِكَ فِي الْمَوْقِعِ')
})
it('should preserve Japanese characters', () => {
const input = 'こんにちゎ、世界!!〆'
const result = sanitizeUserDataForEmail(input)
expect(result).toBe('こんにちゎ、世界!!〆')
})
})

View File

@@ -0,0 +1,75 @@
/**
* Sanitizes user data for emails to prevent injection of HTML, executable code, or other malicious content.
* This function ensures the content is safe by:
* - Removing HTML tags
* - Removing control characters
* - Normalizing whitespace
* - Escaping special HTML characters
* - Allowing only letters, numbers, spaces, and basic punctuation
* - Limiting length (default 100 characters)
*
* @param data - data to sanitize
* @param maxLength - maximum allowed length (default is 100)
* @returns a sanitized string safe to include in email content
*/
export function sanitizeUserDataForEmail(data: unknown, maxLength = 100): string {
if (typeof data !== 'string') {
return ''
}
// Decode HTML numeric entities like &#x3C; or &#60;
const decodedEntities = data
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10)))
// Remove HTML tags
const noTags = decodedEntities.replace(/<[^>]+>/g, '')
const noInvisible = noTags.replace(/[\u200B-\u200F\u2028-\u202F\u2060-\u206F\uFEFF]/g, '')
// Remove control characters except common whitespace
const noControls = [...noInvisible]
.filter((char) => {
const code = char.charCodeAt(0)
return (
code >= 32 || // printable and above
code === 9 || // tab
code === 10 || // new line
code === 13 // return
)
})
.join('')
// Remove '(?' and backticks `
let noInjectionSyntax = noControls.replace(/\(\?/g, '').replace(/`/g, '')
// {{...}} remove braces but keep inner content
noInjectionSyntax = noInjectionSyntax.replace(/\{\{(.*?)\}\}/g, '$1')
// Escape special HTML characters
const escaped = noInjectionSyntax
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Normalize whitespace to single space
const normalizedWhitespace = escaped.replace(/\s+/g, ' ')
// Allow:
// - Unicode letters (\p{L})
// - Unicode numbers (\p{N})
// - Unicode marks (\p{M}, e.g. accents)
// - Unicode spaces (\p{Zs})
// - Punctuation: common ascii + inverted ! and ?
const allowedPunctuation = " .,!?'" + '"¡¿、!()〆-'
// Escape regex special characters
const escapedPunct = allowedPunctuation.replace(/[[\]\\^$*+?.()|{}]/g, '\\$&')
const pattern = `[^\\p{L}\\p{N}\\p{M}\\p{Zs}${escapedPunct}]`
const cleaned = normalizedWhitespace.replace(new RegExp(pattern, 'gu'), '')
// Trim and limit length, trim again to remove trailing spaces
return cleaned.slice(0, maxLength).trim()
}