Skip to content

Commit 40eb6e7

Browse files
authored
PLU-559: HTML injection via pipe name to blacklist emails (#1204)
## Problem Plumber sends error emails via Postman when a recipient is blacklisted. In these emails, the pipe title is directly interpolated into the HTML body without sanitisation. This means if the pipe name includes HTML tags, they will be embedded into the email. This is not really a major issue since Postman sanitises the input before sending the email. ## Solution 1. Sanitise the pipe name using `DOMPurify` 2. Escape the HTML so we display the Pipe name as-is if the user insists on having HTML tags in their pipe names. ## Screenshots <img width="368" height="94" alt="Screenshot 2025-09-10 at 6 02 00 PM" src="https://github.com/user-attachments/assets/a14ee99f-ac1b-40e6-a447-a6f45a0a4513" /> <img width="1117" height="173" alt="Screenshot 2025-09-10 at 6 04 21 PM" src="https://github.com/user-attachments/assets/24fb5d70-90f1-422f-a829-f92a0f81b8f2" /> <img width="250" height="72" alt="Screenshot 2025-09-10 at 6 05 20 PM" src="https://github.com/user-attachments/assets/fbcc7562-35b1-4aa3-97f2-82829bfb9087" /> <img width="1122" height="174" alt="Screenshot 2025-09-10 at 6 05 10 PM" src="https://github.com/user-attachments/assets/6ecf7597-bb01-46a6-b6c3-a09694e76ef6" /> ## Tests - [ ] Normal pipe names (without HTML tags) are sent correctly for blacklisted emails - [ ] Pipe names with dangerous HTML tags are sanitised - [ ] Pipe names with normal HTML tags are displayed as-is
1 parent 93161e8 commit 40eb6e7

File tree

5 files changed

+100
-3
lines changed

5 files changed

+100
-3
lines changed

packages/backend/src/apps/postman/common/send-blacklist-email.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { DateTime } from 'luxon'
22

33
import { redisClient as pipeErrorRedisClient } from '@/helpers/generate-error-email'
4+
import { safeHtml } from '@/helpers/html-utils'
45
import { sendEmail } from '@/helpers/send-email'
56

67
const MAX_LENGTH = 80
@@ -51,7 +52,7 @@ function createBodyErrorMessage(props: BlacklistEmailProps): string {
5152

5253
const formLink = createRequestBlacklistFormLink(props)
5354

54-
const bodyMessage = `
55+
const bodyMessage = safeHtml`
5556
Dear fellow plumber,
5657
<br>
5758
<br>

packages/backend/src/apps/postman/common/send-invalid-attachments-email.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { truncateFlowName } from '@/helpers/generate-error-email'
2+
import { safeHtml } from '@/helpers/html-utils'
23
import { sendEmail } from '@/helpers/send-email'
34

45
interface SendInvalidAttachmentsEmailProps {
@@ -19,7 +20,7 @@ interface CreateMessageProps {
1920
export function createInvalidAttachmentsMessage(props: CreateMessageProps) {
2021
const { flowName, invalidAttachments, executionId, submissionId } = props
2122

22-
const bodyMessage = `
23+
const bodyMessage = safeHtml`
2324
We have detected that your pipe <strong>${flowName}</strong> has attempted to send an email with one or more attachments that are not supported:
2425
<ul>
2526
${invalidAttachments.map((a) => `<li>${a}</li>`).join('\n')}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { escapeHtml, safeHtml } from '../html-utils'
4+
5+
describe('html utils', () => {
6+
describe('escapeHtml', () => {
7+
it.each([
8+
['&', '&amp;'],
9+
['<', '&lt;'],
10+
['>', '&gt;'],
11+
['"', '&quot;'],
12+
["'", '&#039;'],
13+
])('escapes critical chars %s', (char: string, expected: string) => {
14+
expect(escapeHtml(char)).toBe(expected)
15+
})
16+
17+
it('escapes mixed content with tags and attributes', () => {
18+
const s = `<img src=x onerror="alert('xss')"> & more`
19+
const e =
20+
'&lt;img src=x onerror=&quot;alert(&#039;xss&#039;)&quot;&gt; &amp; more'
21+
expect(escapeHtml(s)).toBe(e)
22+
})
23+
24+
it('neutralizes script tags', () => {
25+
const s = '<script>alert(1)</script>'
26+
const e = '&lt;script&gt;alert(1)&lt;/script&gt;'
27+
expect(escapeHtml(s)).toBe(e)
28+
})
29+
30+
it('escapes angle brackets inside text', () => {
31+
const s = '1 < 2 && 3 > 2'
32+
const e = '1 &lt; 2 &amp;&amp; 3 &gt; 2'
33+
expect(escapeHtml(s)).toBe(e)
34+
})
35+
36+
it('handles template-like sequences', () => {
37+
const s = '${alert(1)}<div onclick=alert(1)>'
38+
const e = '${alert(1)}&lt;div onclick=alert(1)&gt;'
39+
expect(escapeHtml(s)).toBe(e)
40+
})
41+
42+
it('leaves safe text unchanged', () => {
43+
expect(escapeHtml('Hello World 123')).toBe('Hello World 123')
44+
expect(escapeHtml('こんにちは مرحبا Здравствуйте')).toBe(
45+
'こんにちは مرحبا Здравствуйте',
46+
)
47+
expect(escapeHtml('🙂🚀€₿')).toBe('🙂🚀€₿')
48+
})
49+
})
50+
})
51+
52+
describe('safeHtml', () => {
53+
it('should escape multiple HTML values in a string', () => {
54+
const testVariable = '<b>Hello</b>'
55+
const testVariable2 = '<script>alert("XSS")</script>'
56+
57+
const expected = `
58+
This is a test:&lt;b&gt;Hello&lt;/b&gt;.
59+
With a new line: &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;
60+
`
61+
expect(safeHtml`
62+
This is a test:${testVariable}.
63+
With a new line: ${testVariable2}
64+
`).toBe(expected)
65+
})
66+
})

packages/backend/src/helpers/generate-error-email.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { createRedisClient, REDIS_DB_INDEX } from '@/config/redis'
55
import { sendEmail } from '@/helpers/send-email'
66
import Flow from '@/models/flow'
77

8+
import { safeHtml } from './html-utils'
9+
810
const MAX_LENGTH = 80
911
export const redisClient = createRedisClient(REDIS_DB_INDEX.PIPE_ERRORS)
1012

@@ -26,7 +28,7 @@ export function createBodyErrorMessage(
2628
const redirectUrl = `/execution-pipe/${pipeId}?${searchParams.toString()}`
2729
const formattedUrl = `${appPrefixUrl}${redirectUrl}`
2830

29-
const bodyMessage = `
31+
const bodyMessage = safeHtml`
3032
Dear fellow plumber,
3133
<br>
3234
<br>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// function to escape HTML in string
2+
function escapeHtml(unsafe: string): string {
3+
return unsafe
4+
.replace(/&/g, '&amp;')
5+
.replace(/</g, '&lt;')
6+
.replace(/>/g, '&gt;')
7+
.replace(/"/g, '&quot;')
8+
.replace(/'/g, '&#039;')
9+
}
10+
11+
// tag function for safe HTML templates
12+
function safeHtml(strings: TemplateStringsArray, ...values: string[]): string {
13+
let result = ''
14+
15+
// Interleave strings and escaped values
16+
for (let i = 0; i < strings.length; i++) {
17+
result += strings[i]
18+
19+
if (i < values.length) {
20+
result += escapeHtml(values[i])
21+
}
22+
}
23+
24+
return result
25+
}
26+
27+
export { escapeHtml, safeHtml }

0 commit comments

Comments
 (0)