Skip to content

Commit f624ab9

Browse files
priosshrsthclaude
andcommitted
fix(reminders): escape task titles in grouped email HTML bodies (OUT-3861)
Task titles are user-controlled and were interpolated verbatim into the htmlBody of grouped emails. Escape them via a shared escapeHtml util to prevent markup injection in the rendered email. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent de5c509 commit f624ab9

5 files changed

Lines changed: 26 additions & 2 deletions

File tree

src/app/api/notification/groupedEmail.renderer.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,14 @@ describe('renderGroupedEmail', () => {
109109
it('returns an empty body when there are no sections', () => {
110110
expect(renderGroupedEmail({ totalEventCount: 0, sections: [] }).htmlBody).toBe('')
111111
})
112+
113+
it('escapes HTML in task names to prevent markup injection', () => {
114+
const email = renderGroupedEmail({
115+
totalEventCount: 1,
116+
sections: [section({ taskNames: [`</li><img src=x onerror="alert(1)">`] })],
117+
})
118+
119+
expect(email.htmlBody).not.toContain('<img')
120+
expect(email.htmlBody).toContain('&lt;/li&gt;&lt;img src=x onerror=&quot;alert(1)&quot;&gt;')
121+
})
112122
})

src/app/api/notification/groupedEmail.renderer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { GroupedEmailEventType } from '@prisma/client'
2+
import { escapeHtml } from '@/utils/escapeHtml'
23
import { GroupedEmailContent, GroupedEmailSection } from './groupedEmail.composer'
34

45
export const GROUPED_EMAIL_HEADER = 'Catch up on task activity'
@@ -22,7 +23,7 @@ const sectionHeading: Record<GroupedEmailEventType, (count: number) => string> =
2223
}
2324

2425
const renderSection = (section: GroupedEmailSection): string => {
25-
const items = section.taskNames.map((name) => `<li>'${name}'</li>`).join('')
26+
const items = section.taskNames.map((name) => `<li>'${escapeHtml(name)}'</li>`).join('')
2627
const overflow =
2728
section.overflowCount > 0
2829
? `<em>+${section.overflowCount} other ${pluralize(section.overflowCount, 'task', 'tasks')}</em>`

src/app/api/notification/groupedReminderEmail.renderer.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,12 @@ describe('renderGroupedReminderEmail', () => {
4848
])
4949
expect(result.htmlBody).toContain(expectedLabel)
5050
})
51+
52+
it('escapes HTML in task titles to prevent markup injection', () => {
53+
const result = renderGroupedReminderEmail([
54+
{ taskTitle: `</li><img src=x onerror="alert(1)">`, reminderType: TaskReminderType.DUE_DATE_TODAY },
55+
])
56+
expect(result.htmlBody).not.toContain('<img')
57+
expect(result.htmlBody).toContain('&lt;/li&gt;&lt;img src=x onerror=&quot;alert(1)&quot;&gt;')
58+
})
5159
})

src/app/api/notification/groupedReminderEmail.renderer.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { TaskReminderType } from '@prisma/client'
2+
import { escapeHtml } from '@/utils/escapeHtml'
23

34
export type ReminderEntry = {
45
taskTitle: string
@@ -23,7 +24,9 @@ const reminderLabel: Record<TaskReminderType, string> = {
2324

2425
export const renderGroupedReminderEmail = (entries: ReminderEntry[]): GroupedReminderEmailDetails => {
2526
const n = entries.length
26-
const items = entries.map((e) => `<li>'${e.taskTitle}' – <em>${reminderLabel[e.reminderType]}</em></li>`).join('')
27+
const items = entries
28+
.map((e) => `<li>'${escapeHtml(e.taskTitle)}' – <em>${reminderLabel[e.reminderType]}</em></li>`)
29+
.join('')
2730
return {
2831
subject: `[Reminder] You have ${n} ${n === 1 ? 'task' : 'tasks'} to complete`,
2932
header: 'Task reminders',

src/utils/escapeHtml.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const escapeHtml = (value: string): string =>
2+
value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')

0 commit comments

Comments
 (0)