Skip to content

Commit 9f1bff5

Browse files
authored
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`**
1 parent 4c25357 commit 9f1bff5

File tree

5 files changed

+238
-2
lines changed

5 files changed

+238
-2
lines changed

examples/email/src/collections/Newsletter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { CollectionConfig } from 'payload'
2+
import { sanitizeUserDataForEmail } from 'payload/shared'
23

34
import { generateEmailHTML } from '../email/generateEmailHTML'
45

@@ -26,7 +27,7 @@ export const Newsletter: CollectionConfig = {
2627
.sendEmail({
2728
from: 'sender@example.com',
2829
html: await generateEmailHTML({
29-
content: `<p>${doc.name ? `Hi ${doc.name}!` : 'Hi!'} We'll be in touch soon...</p>`,
30+
content: `<p>${doc.name ? `Hi ${sanitizeUserDataForEmail(doc.name)}!` : 'Hi!'} We'll be in touch soon...</p>`,
3031
headline: 'Welcome to the newsletter!',
3132
}),
3233
subject: 'Thanks for signing up!',

examples/email/src/email/generateVerificationEmail.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { generateEmailHTML } from './generateEmailHTML'
22

3+
import { sanitizeUserDataForEmail } from 'payload/shared'
4+
35
type User = {
46
email: string
57
name?: string
@@ -16,7 +18,7 @@ export const generateVerificationEmail = async (
1618
const { token, user } = args
1719

1820
return generateEmailHTML({
19-
content: `<p>Hi${user.name ? ' ' + user.name : ''}! Validate your account by clicking the button below.</p>`,
21+
content: `<p>Hi${user.name ? ' ' + sanitizeUserDataForEmail(user.name) : ''}! Validate your account by clicking the button below.</p>`,
2022
cta: {
2123
buttonLabel: 'Verify',
2224
url: `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/verify?token=${token}&email=${user.email}`,

packages/payload/src/exports/shared.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ export {
112112

113113
export { reduceFieldsToValues } from '../utilities/reduceFieldsToValues.js'
114114

115+
export { sanitizeUserDataForEmail } from '../utilities/sanitizeUserDataForEmail.js'
116+
115117
export { setsAreEqual } from '../utilities/setsAreEqual.js'
116118

117119
export { toKebabCase } from '../utilities/toKebabCase.js'
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { sanitizeUserDataForEmail } from './sanitizeUserDataForEmail'
2+
3+
describe('sanitizeUserDataForEmail', () => {
4+
it('should remove anchor tags', () => {
5+
const input = '<a href="https://example.com">Click me</a>'
6+
const result = sanitizeUserDataForEmail(input)
7+
expect(result).toBe('Click me')
8+
})
9+
10+
it('should remove script tags', () => {
11+
const unsanitizedData = '<script>alert</script>'
12+
const sanitizedData = sanitizeUserDataForEmail(unsanitizedData)
13+
expect(sanitizedData).toBe('alert')
14+
})
15+
16+
it('should remove mixed-case script tags', () => {
17+
const input = '<ScRipT>alert(1)</sCrIpT>'
18+
const result = sanitizeUserDataForEmail(input)
19+
expect(result).toBe('alert1')
20+
})
21+
22+
it('should remove embedded base64-encoded scripts', () => {
23+
const input = '<img src="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">'
24+
const result = sanitizeUserDataForEmail(input)
25+
expect(result).toBe('')
26+
})
27+
28+
it('should remove iframe elements', () => {
29+
const input = '<iframe src="malicious.com"></iframe>Frame'
30+
const result = sanitizeUserDataForEmail(input)
31+
expect(result).toBe('Frame')
32+
})
33+
34+
it('should remove javascript: links in attributes', () => {
35+
const input = '<a href="javascript:alert(1)">click</a>'
36+
const result = sanitizeUserDataForEmail(input)
37+
expect(result).toBe('click')
38+
})
39+
40+
it('should remove mismatched script input', () => {
41+
const input = '<script>console.log("test")'
42+
const result = sanitizeUserDataForEmail(input)
43+
expect(result).toBe('console.log\"test\"')
44+
})
45+
46+
it('should remove encoded scripts via HTML entities', () => {
47+
const input = '&#x3C;script&#x3E;alert(1)&#x3C;/script&#x3E;'
48+
const result = sanitizeUserDataForEmail(input)
49+
expect(result).toBe('alert1')
50+
})
51+
52+
it('should remove template injection syntax', () => {
53+
const input = '{{7*7}}'
54+
const result = sanitizeUserDataForEmail(input)
55+
expect(result).toBe('77')
56+
})
57+
58+
it('should remove invisible zero-width characters', () => {
59+
const input = 'a\u200Bler\u200Bt("XSS")'
60+
const result = sanitizeUserDataForEmail(input)
61+
expect(result).toBe('alert\"XSS\"')
62+
})
63+
64+
it('should remove CSS expressions within style attributes', () => {
65+
const input = '<div style="width: expression(alert(\'XSS\'));">Hello</div>'
66+
const result = sanitizeUserDataForEmail(input)
67+
expect(result).toBe('Hello')
68+
})
69+
70+
it('should not render SVG with onload event', () => {
71+
const input = '<svg onload="alert(\'XSS\')">Graphic</svg>'
72+
const result = sanitizeUserDataForEmail(input)
73+
expect(result).toBe('Graphic')
74+
})
75+
76+
it('should not allow backtick-based patterns', () => {
77+
const input = '`alert("XSS")`'
78+
const result = sanitizeUserDataForEmail(input)
79+
expect(result).toBe('alert\"XSS\"')
80+
})
81+
82+
it('should preserve allowed punctuation', () => {
83+
const input = `Hello "world" - it's safe!`
84+
const result = sanitizeUserDataForEmail(input)
85+
expect(result).toBe(`Hello "world" - it's safe!`)
86+
})
87+
88+
it('should return empty string for non-string input', () => {
89+
expect(sanitizeUserDataForEmail(null)).toBe('')
90+
expect(sanitizeUserDataForEmail(undefined)).toBe('')
91+
expect(sanitizeUserDataForEmail(123)).toBe('')
92+
expect(sanitizeUserDataForEmail({})).toBe('')
93+
})
94+
95+
it('should return empty string for an empty string', () => {
96+
expect(sanitizeUserDataForEmail('')).toBe('')
97+
})
98+
99+
it('should collapse excessive whitespace', () => {
100+
const input = 'This is \n\n a test'
101+
expect(sanitizeUserDataForEmail(input)).toBe('This is a test')
102+
})
103+
104+
it('should truncate to maxLength characters', () => {
105+
const input = 'a'.repeat(200)
106+
const result = sanitizeUserDataForEmail(input, 50)
107+
expect(result.length).toBe(50)
108+
})
109+
110+
it('should remove characters outside allowed punctuation', () => {
111+
const input = 'Hello @#$%^*()_+=[]{}|\\~`'
112+
const result = sanitizeUserDataForEmail(input)
113+
expect(result).toBe('Hello')
114+
})
115+
it('should sanitize syntax in regex-like input', () => {
116+
const input = '(?=XSS)(?:abc)'
117+
const result = sanitizeUserDataForEmail(input)
118+
expect(result).toBe('XSSabc')
119+
})
120+
121+
it('should handle string of only control characters', () => {
122+
const input = '\x01\x02\x03\x04'
123+
const result = sanitizeUserDataForEmail(input)
124+
expect(result).toBe('')
125+
})
126+
127+
it('should sanitize complex script attempt with mixed encoding', () => {
128+
const input = '&#x3C;script&#x3E;alert(String.fromCharCode(88,83,83))&#x3C;/script&#x3E;'
129+
const result = sanitizeUserDataForEmail(input)
130+
expect(result).toBe('alertString.fromCharCode88,83,83')
131+
})
132+
133+
it('should handle deeply nested HTML tags correctly', () => {
134+
const input = `<div><section><article><p>Hello <strong>world <em>from <span>deep <a href="#">tags</a></span></em></strong></p></article></section></div>`
135+
const result = sanitizeUserDataForEmail(input)
136+
expect(result).toBe('Hello world from deep tags')
137+
})
138+
139+
it('should preserve accented Spanish characters', () => {
140+
const input = '¡Hola! ¿Cómo estás? ÁÉÍÓÚ ÜÑ ñáéíóú ü'
141+
const result = sanitizeUserDataForEmail(input)
142+
expect(result).toBe('¡Hola! ¿Cómo estás? ÁÉÍÓÚ ÜÑ ñáéíóú ü')
143+
})
144+
145+
it('should preserve Arabic characters with diacritics', () => {
146+
const input = 'مَرْحَبًا بِكَ فِي الْمَوْقِعِ'
147+
const result = sanitizeUserDataForEmail(input)
148+
expect(result).toBe('مَرْحَبًا بِكَ فِي الْمَوْقِعِ')
149+
})
150+
151+
it('should preserve Japanese characters', () => {
152+
const input = 'こんにちゎ、世界!!〆'
153+
const result = sanitizeUserDataForEmail(input)
154+
expect(result).toBe('こんにちゎ、世界!!〆')
155+
})
156+
})
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Sanitizes user data for emails to prevent injection of HTML, executable code, or other malicious content.
3+
* This function ensures the content is safe by:
4+
* - Removing HTML tags
5+
* - Removing control characters
6+
* - Normalizing whitespace
7+
* - Escaping special HTML characters
8+
* - Allowing only letters, numbers, spaces, and basic punctuation
9+
* - Limiting length (default 100 characters)
10+
*
11+
* @param data - data to sanitize
12+
* @param maxLength - maximum allowed length (default is 100)
13+
* @returns a sanitized string safe to include in email content
14+
*/
15+
export function sanitizeUserDataForEmail(data: unknown, maxLength = 100): string {
16+
if (typeof data !== 'string') {
17+
return ''
18+
}
19+
20+
// Decode HTML numeric entities like &#x3C; or &#60;
21+
const decodedEntities = data
22+
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
23+
.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10)))
24+
25+
// Remove HTML tags
26+
const noTags = decodedEntities.replace(/<[^>]+>/g, '')
27+
28+
const noInvisible = noTags.replace(/[\u200B-\u200F\u2028-\u202F\u2060-\u206F\uFEFF]/g, '')
29+
30+
// Remove control characters except common whitespace
31+
const noControls = [...noInvisible]
32+
.filter((char) => {
33+
const code = char.charCodeAt(0)
34+
return (
35+
code >= 32 || // printable and above
36+
code === 9 || // tab
37+
code === 10 || // new line
38+
code === 13 // return
39+
)
40+
})
41+
.join('')
42+
43+
// Remove '(?' and backticks `
44+
let noInjectionSyntax = noControls.replace(/\(\?/g, '').replace(/`/g, '')
45+
46+
// {{...}} remove braces but keep inner content
47+
noInjectionSyntax = noInjectionSyntax.replace(/\{\{(.*?)\}\}/g, '$1')
48+
49+
// Escape special HTML characters
50+
const escaped = noInjectionSyntax
51+
.replace(/&/g, '&amp;')
52+
.replace(/</g, '&lt;')
53+
.replace(/>/g, '&gt;')
54+
55+
// Normalize whitespace to single space
56+
const normalizedWhitespace = escaped.replace(/\s+/g, ' ')
57+
58+
// Allow:
59+
// - Unicode letters (\p{L})
60+
// - Unicode numbers (\p{N})
61+
// - Unicode marks (\p{M}, e.g. accents)
62+
// - Unicode spaces (\p{Zs})
63+
// - Punctuation: common ascii + inverted ! and ?
64+
const allowedPunctuation = " .,!?'" + '"¡¿、!()〆-'
65+
66+
// Escape regex special characters
67+
const escapedPunct = allowedPunctuation.replace(/[[\]\\^$*+?.()|{}]/g, '\\$&')
68+
69+
const pattern = `[^\\p{L}\\p{N}\\p{M}\\p{Zs}${escapedPunct}]`
70+
71+
const cleaned = normalizedWhitespace.replace(new RegExp(pattern, 'gu'), '')
72+
73+
// Trim and limit length, trim again to remove trailing spaces
74+
return cleaned.slice(0, maxLength).trim()
75+
}

0 commit comments

Comments
 (0)