Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 33 additions & 10 deletions lib/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@

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

/**
* Escape text before interpolating it into an HTML email body. Titles, brand
* names and actors can come from external/untrusted sources (e.g. a GitHub
* issue title), so raw interpolation would let them inject markup into the
* rendered email. Plain-text bodies don't need this.
*/
function escapeHtml(value: string | number): string {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

export function isEmailConfigured(): boolean {
return Boolean(process.env.RESEND_API_KEY);
}
Expand Down Expand Up @@ -84,23 +99,25 @@ export function sendWaitlistVerification(opts: {
`You're almost in. Confirm your email to lock in your spot on the ${brand} waitlist:\n\n` +
`${url}\n\n` +
`If you didn't request this, just ignore it — no account is created.`;
const safeBrand = escapeHtml(brand);
const safeUrl = escapeHtml(url);
const html = `<!doctype html>
<html><body style="margin:0;background:#0b0b0c;color:#e7e7e7;font-family:ui-monospace,Menlo,Consolas,monospace">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px">
<tr><td align="center">
<table role="presentation" width="100%" style="max-width:480px;background:#141416;border:1px solid #26262a;border-radius:12px;padding:28px">
<tr><td>
<h1 style="margin:0 0 8px;font-size:22px;color:#9EF01A">${brand} 🤘</h1>
<h1 style="margin:0 0 8px;font-size:22px;color:#9EF01A">${safeBrand} 🤘</h1>
<p style="margin:0 0 20px;line-height:1.5;color:#c9c9c9">
You're almost in. Confirm your email to lock in your spot in the pit.
</p>
<p style="margin:0 0 24px">
<a href="${url}" style="display:inline-block;background:#9EF01A;color:#0b0b0c;text-decoration:none;font-weight:700;padding:12px 20px;border-radius:8px">
<a href="${safeUrl}" style="display:inline-block;background:#9EF01A;color:#0b0b0c;text-decoration:none;font-weight:700;padding:12px 20px;border-radius:8px">
Confirm my spot
</a>
</p>
<p style="margin:0;font-size:12px;color:#8a8a8a;line-height:1.5;word-break:break-all">
Or paste this link:<br>${url}
Or paste this link:<br>${safeUrl}
</p>
<p style="margin:18px 0 0;font-size:12px;color:#6a6a6a">
Didn't sign up? Ignore this — nothing happens.
Expand Down Expand Up @@ -129,17 +146,21 @@ export function sendGithubClosedNotification(opts: {
const text =
`${opts.actor} ${verb} ${opts.kind} #${opts.number} in ${opts.repo}\n\n` +
`${opts.title}\n${opts.url}\n`;
const safeRepo = escapeHtml(opts.repo);
const safeTitle = escapeHtml(opts.title);
const safeActor = escapeHtml(opts.actor);
const safeUrl = escapeHtml(opts.url);
const html = `<!doctype html>
<html><body style="margin:0;background:#0b0b0c;color:#e7e7e7;font-family:ui-monospace,Menlo,Consolas,monospace">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="padding:28px 16px">
<tr><td align="center">
<table role="presentation" width="100%" style="max-width:480px;background:#141416;border:1px solid #26262a;border-radius:12px;padding:24px">
<tr><td>
<p style="margin:0 0 6px;font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:#8a8a8a">${opts.repo}</p>
<p style="margin:0 0 6px;font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:#8a8a8a">${safeRepo}</p>
<h1 style="margin:0 0 12px;font-size:18px;color:#9EF01A">${opts.kind} #${opts.number} ${verb} 🤘</h1>
<p style="margin:0 0 16px;line-height:1.5;color:#d6d6d6">${opts.title}</p>
<p style="margin:0 0 18px;font-size:13px;color:#9a9a9a">by ${opts.actor}</p>
<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>
<p style="margin:0 0 16px;line-height:1.5;color:#d6d6d6">${safeTitle}</p>
<p style="margin:0 0 18px;font-size:13px;color:#9a9a9a">by ${safeActor}</p>
<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>
</td></tr>
</table>
</td></tr>
Expand All @@ -161,23 +182,25 @@ export function sendPasswordReset(opts: {
`Someone asked to reset the password for your ${brand} account.\n\n` +
`Set a new one here (link expires in 1 hour):\n${url}\n\n` +
`If this wasn't you, ignore this email — your password stays unchanged.`;
const safeBrand = escapeHtml(brand);
const safeUrl = escapeHtml(url);
const html = `<!doctype html>
<html><body style="margin:0;background:#0b0b0c;color:#e7e7e7;font-family:ui-monospace,Menlo,Consolas,monospace">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px">
<tr><td align="center">
<table role="presentation" width="100%" style="max-width:480px;background:#141416;border:1px solid #26262a;border-radius:12px;padding:28px">
<tr><td>
<h1 style="margin:0 0 8px;font-size:22px;color:#9EF01A">${brand} 🤘</h1>
<h1 style="margin:0 0 8px;font-size:22px;color:#9EF01A">${safeBrand} 🤘</h1>
<p style="margin:0 0 20px;line-height:1.5;color:#c9c9c9">
Reset your password. This link expires in 1 hour.
</p>
<p style="margin:0 0 24px">
<a href="${url}" style="display:inline-block;background:#9EF01A;color:#0b0b0c;text-decoration:none;font-weight:700;padding:12px 20px;border-radius:8px">
<a href="${safeUrl}" style="display:inline-block;background:#9EF01A;color:#0b0b0c;text-decoration:none;font-weight:700;padding:12px 20px;border-radius:8px">
Set a new password
</a>
</p>
<p style="margin:0;font-size:12px;color:#8a8a8a;line-height:1.5;word-break:break-all">
Or paste this link:<br>${url}
Or paste this link:<br>${safeUrl}
</p>
<p style="margin:18px 0 0;font-size:12px;color:#6a6a6a">
Didn't ask for this? Ignore it — nothing changes.
Expand Down
74 changes: 74 additions & 0 deletions tests/email-escape.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
sendGithubClosedNotification,
sendWaitlistVerification,
sendPasswordReset,
} from "../lib/email.ts";

/**
* These emails interpolate external/tenant-controlled values (a GitHub issue
* title, the actor, the tenant brand) into an HTML body. Raw interpolation
* let those values inject markup into the rendered email. Capture the outgoing
* Resend payload and assert the HTML body is escaped.
*/
async function capturePayload(run) {
const prevKey = process.env.RESEND_API_KEY;
const prevFetch = globalThis.fetch;
process.env.RESEND_API_KEY = "test_key"; // force the real send() path
let captured = null;
globalThis.fetch = async (_url, opts) => {
captured = JSON.parse(opts.body);
return { ok: true, json: async () => ({ id: "x" }), text: async () => "" };
};
try {
await run();
} finally {
globalThis.fetch = prevFetch;
if (prevKey === undefined) delete process.env.RESEND_API_KEY;
else process.env.RESEND_API_KEY = prevKey;
}
return captured;
}

test("github notification escapes an attacker-controlled issue title in the HTML body", async () => {
const payload = await capturePayload(() =>
sendGithubClosedNotification({
to: "owner@example.com",
kind: "issue",
repo: "moshcoder/moshcode",
number: 1,
title: "<script>alert(document.cookie)</script>",
url: "https://github.com/x",
actor: 'evil"><b>pwn</b>',
merged: false,
}),
);
assert.ok(!payload.html.includes("<script>"), "raw <script> must not reach the HTML body");
assert.ok(!payload.html.includes("<b>pwn</b>"), "raw actor markup must not reach the HTML body");
assert.ok(payload.html.includes("&lt;script&gt;"), "title should be HTML-escaped");
});

test("waitlist email escapes a malicious tenant brand in the HTML body", async () => {
const payload = await capturePayload(() =>
sendWaitlistVerification({ email: "u@e.com", token: "t", brand: "<img src=x onerror=alert(1)>" }),
);
assert.ok(!payload.html.includes("<img src=x"), "raw brand markup must not reach the HTML body");
assert.ok(payload.html.includes("&lt;img src=x"), "brand should be HTML-escaped");
});

test("password-reset email escapes a malicious tenant brand in the HTML body", async () => {
const payload = await capturePayload(() =>
sendPasswordReset({ email: "u@e.com", token: "t", brand: "<b>x</b>" }),
);
assert.ok(!payload.html.includes("<b>x</b>"), "raw brand markup must not reach the HTML body");
assert.ok(payload.html.includes("&lt;b&gt;x&lt;/b&gt;"), "brand should be HTML-escaped");
});

test("a benign brand still renders as normal text", async () => {
const payload = await capturePayload(() =>
sendWaitlistVerification({ email: "u@e.com", token: "t", brand: "moshcode" }),
);
assert.ok(payload.html.includes("moshcode 🤘"), "ordinary brand names must be unaffected");
});