Skip to content

Commit e1fba88

Browse files
fix(email): HTML-escape interpolated values in email templates
Titles, actors and tenant brand names were interpolated straight into the HTML email bodies. A GitHub issue/PR title is attacker-controlled, so sendGithubClosedNotification rendered untrusted markup (e.g. <script>/<img onerror>) into the owner's notification email; the same applied to the tenant brand in the waitlist and password-reset emails. Add an escapeHtml helper and apply it to every dynamic value that lands in an HTML body. Plain-text bodies and subjects are left untouched. Adds tests/email-escape.test.mjs (fails before, passes after).
1 parent 385b99a commit e1fba88

2 files changed

Lines changed: 107 additions & 10 deletions

File tree

lib/email.ts

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,21 @@
66

77
const RESEND_ENDPOINT = "https://api.resend.com/emails";
88

9+
/**
10+
* Escape text before interpolating it into an HTML email body. Titles, brand
11+
* names and actors can come from external/untrusted sources (e.g. a GitHub
12+
* issue title), so raw interpolation would let them inject markup into the
13+
* rendered email. Plain-text bodies don't need this.
14+
*/
15+
function escapeHtml(value: string | number): string {
16+
return String(value)
17+
.replace(/&/g, "&amp;")
18+
.replace(/</g, "&lt;")
19+
.replace(/>/g, "&gt;")
20+
.replace(/"/g, "&quot;")
21+
.replace(/'/g, "&#39;");
22+
}
23+
924
export function isEmailConfigured(): boolean {
1025
return Boolean(process.env.RESEND_API_KEY);
1126
}
@@ -84,23 +99,25 @@ export function sendWaitlistVerification(opts: {
8499
`You're almost in. Confirm your email to lock in your spot on the ${brand} waitlist:\n\n` +
85100
`${url}\n\n` +
86101
`If you didn't request this, just ignore it — no account is created.`;
102+
const safeBrand = escapeHtml(brand);
103+
const safeUrl = escapeHtml(url);
87104
const html = `<!doctype html>
88105
<html><body style="margin:0;background:#0b0b0c;color:#e7e7e7;font-family:ui-monospace,Menlo,Consolas,monospace">
89106
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px">
90107
<tr><td align="center">
91108
<table role="presentation" width="100%" style="max-width:480px;background:#141416;border:1px solid #26262a;border-radius:12px;padding:28px">
92109
<tr><td>
93-
<h1 style="margin:0 0 8px;font-size:22px;color:#9EF01A">${brand} 🤘</h1>
110+
<h1 style="margin:0 0 8px;font-size:22px;color:#9EF01A">${safeBrand} 🤘</h1>
94111
<p style="margin:0 0 20px;line-height:1.5;color:#c9c9c9">
95112
You're almost in. Confirm your email to lock in your spot in the pit.
96113
</p>
97114
<p style="margin:0 0 24px">
98-
<a href="${url}" style="display:inline-block;background:#9EF01A;color:#0b0b0c;text-decoration:none;font-weight:700;padding:12px 20px;border-radius:8px">
115+
<a href="${safeUrl}" style="display:inline-block;background:#9EF01A;color:#0b0b0c;text-decoration:none;font-weight:700;padding:12px 20px;border-radius:8px">
99116
Confirm my spot
100117
</a>
101118
</p>
102119
<p style="margin:0;font-size:12px;color:#8a8a8a;line-height:1.5;word-break:break-all">
103-
Or paste this link:<br>${url}
120+
Or paste this link:<br>${safeUrl}
104121
</p>
105122
<p style="margin:18px 0 0;font-size:12px;color:#6a6a6a">
106123
Didn't sign up? Ignore this — nothing happens.
@@ -129,17 +146,21 @@ export function sendGithubClosedNotification(opts: {
129146
const text =
130147
`${opts.actor} ${verb} ${opts.kind} #${opts.number} in ${opts.repo}\n\n` +
131148
`${opts.title}\n${opts.url}\n`;
149+
const safeRepo = escapeHtml(opts.repo);
150+
const safeTitle = escapeHtml(opts.title);
151+
const safeActor = escapeHtml(opts.actor);
152+
const safeUrl = escapeHtml(opts.url);
132153
const html = `<!doctype html>
133154
<html><body style="margin:0;background:#0b0b0c;color:#e7e7e7;font-family:ui-monospace,Menlo,Consolas,monospace">
134155
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="padding:28px 16px">
135156
<tr><td align="center">
136157
<table role="presentation" width="100%" style="max-width:480px;background:#141416;border:1px solid #26262a;border-radius:12px;padding:24px">
137158
<tr><td>
138-
<p style="margin:0 0 6px;font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:#8a8a8a">${opts.repo}</p>
159+
<p style="margin:0 0 6px;font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:#8a8a8a">${safeRepo}</p>
139160
<h1 style="margin:0 0 12px;font-size:18px;color:#9EF01A">${opts.kind} #${opts.number} ${verb} 🤘</h1>
140-
<p style="margin:0 0 16px;line-height:1.5;color:#d6d6d6">${opts.title}</p>
141-
<p style="margin:0 0 18px;font-size:13px;color:#9a9a9a">by ${opts.actor}</p>
142-
<a href="${opts.url}" style="display:inline-block;background:#9EF01A;color:#0b0b0c;text-decoration:none;font-weight:700;padding:10px 18px;border-radius:8px">View on GitHub</a>
161+
<p style="margin:0 0 16px;line-height:1.5;color:#d6d6d6">${safeTitle}</p>
162+
<p style="margin:0 0 18px;font-size:13px;color:#9a9a9a">by ${safeActor}</p>
163+
<a href="${safeUrl}" style="display:inline-block;background:#9EF01A;color:#0b0b0c;text-decoration:none;font-weight:700;padding:10px 18px;border-radius:8px">View on GitHub</a>
143164
</td></tr>
144165
</table>
145166
</td></tr>
@@ -161,23 +182,25 @@ export function sendPasswordReset(opts: {
161182
`Someone asked to reset the password for your ${brand} account.\n\n` +
162183
`Set a new one here (link expires in 1 hour):\n${url}\n\n` +
163184
`If this wasn't you, ignore this email — your password stays unchanged.`;
185+
const safeBrand = escapeHtml(brand);
186+
const safeUrl = escapeHtml(url);
164187
const html = `<!doctype html>
165188
<html><body style="margin:0;background:#0b0b0c;color:#e7e7e7;font-family:ui-monospace,Menlo,Consolas,monospace">
166189
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px">
167190
<tr><td align="center">
168191
<table role="presentation" width="100%" style="max-width:480px;background:#141416;border:1px solid #26262a;border-radius:12px;padding:28px">
169192
<tr><td>
170-
<h1 style="margin:0 0 8px;font-size:22px;color:#9EF01A">${brand} 🤘</h1>
193+
<h1 style="margin:0 0 8px;font-size:22px;color:#9EF01A">${safeBrand} 🤘</h1>
171194
<p style="margin:0 0 20px;line-height:1.5;color:#c9c9c9">
172195
Reset your password. This link expires in 1 hour.
173196
</p>
174197
<p style="margin:0 0 24px">
175-
<a href="${url}" style="display:inline-block;background:#9EF01A;color:#0b0b0c;text-decoration:none;font-weight:700;padding:12px 20px;border-radius:8px">
198+
<a href="${safeUrl}" style="display:inline-block;background:#9EF01A;color:#0b0b0c;text-decoration:none;font-weight:700;padding:12px 20px;border-radius:8px">
176199
Set a new password
177200
</a>
178201
</p>
179202
<p style="margin:0;font-size:12px;color:#8a8a8a;line-height:1.5;word-break:break-all">
180-
Or paste this link:<br>${url}
203+
Or paste this link:<br>${safeUrl}
181204
</p>
182205
<p style="margin:18px 0 0;font-size:12px;color:#6a6a6a">
183206
Didn't ask for this? Ignore it — nothing changes.

tests/email-escape.test.mjs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import {
5+
sendGithubClosedNotification,
6+
sendWaitlistVerification,
7+
sendPasswordReset,
8+
} from "../lib/email.ts";
9+
10+
/**
11+
* These emails interpolate external/tenant-controlled values (a GitHub issue
12+
* title, the actor, the tenant brand) into an HTML body. Raw interpolation
13+
* let those values inject markup into the rendered email. Capture the outgoing
14+
* Resend payload and assert the HTML body is escaped.
15+
*/
16+
async function capturePayload(run) {
17+
const prevKey = process.env.RESEND_API_KEY;
18+
const prevFetch = globalThis.fetch;
19+
process.env.RESEND_API_KEY = "test_key"; // force the real send() path
20+
let captured = null;
21+
globalThis.fetch = async (_url, opts) => {
22+
captured = JSON.parse(opts.body);
23+
return { ok: true, json: async () => ({ id: "x" }), text: async () => "" };
24+
};
25+
try {
26+
await run();
27+
} finally {
28+
globalThis.fetch = prevFetch;
29+
if (prevKey === undefined) delete process.env.RESEND_API_KEY;
30+
else process.env.RESEND_API_KEY = prevKey;
31+
}
32+
return captured;
33+
}
34+
35+
test("github notification escapes an attacker-controlled issue title in the HTML body", async () => {
36+
const payload = await capturePayload(() =>
37+
sendGithubClosedNotification({
38+
to: "owner@example.com",
39+
kind: "issue",
40+
repo: "moshcoder/moshcode",
41+
number: 1,
42+
title: "<script>alert(document.cookie)</script>",
43+
url: "https://github.com/x",
44+
actor: 'evil"><b>pwn</b>',
45+
merged: false,
46+
}),
47+
);
48+
assert.ok(!payload.html.includes("<script>"), "raw <script> must not reach the HTML body");
49+
assert.ok(!payload.html.includes("<b>pwn</b>"), "raw actor markup must not reach the HTML body");
50+
assert.ok(payload.html.includes("&lt;script&gt;"), "title should be HTML-escaped");
51+
});
52+
53+
test("waitlist email escapes a malicious tenant brand in the HTML body", async () => {
54+
const payload = await capturePayload(() =>
55+
sendWaitlistVerification({ email: "u@e.com", token: "t", brand: "<img src=x onerror=alert(1)>" }),
56+
);
57+
assert.ok(!payload.html.includes("<img src=x"), "raw brand markup must not reach the HTML body");
58+
assert.ok(payload.html.includes("&lt;img src=x"), "brand should be HTML-escaped");
59+
});
60+
61+
test("password-reset email escapes a malicious tenant brand in the HTML body", async () => {
62+
const payload = await capturePayload(() =>
63+
sendPasswordReset({ email: "u@e.com", token: "t", brand: "<b>x</b>" }),
64+
);
65+
assert.ok(!payload.html.includes("<b>x</b>"), "raw brand markup must not reach the HTML body");
66+
assert.ok(payload.html.includes("&lt;b&gt;x&lt;/b&gt;"), "brand should be HTML-escaped");
67+
});
68+
69+
test("a benign brand still renders as normal text", async () => {
70+
const payload = await capturePayload(() =>
71+
sendWaitlistVerification({ email: "u@e.com", token: "t", brand: "moshcode" }),
72+
);
73+
assert.ok(payload.html.includes("moshcode 🤘"), "ordinary brand names must be unaffected");
74+
});

0 commit comments

Comments
 (0)