- ${brand} 🤘
+ ${safeBrand} 🤘
Reset your password. This link expires in 1 hour.
-
+
Set a new password
- Or paste this link: ${url}
+ Or paste this link: ${safeUrl}
Didn't ask for this? Ignore it — nothing changes.
diff --git a/tests/email-escape.test.mjs b/tests/email-escape.test.mjs
new file mode 100644
index 0000000..e84dcd5
--- /dev/null
+++ b/tests/email-escape.test.mjs
@@ -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: "",
+ url: "https://github.com/x",
+ actor: 'evil">pwn',
+ merged: false,
+ }),
+ );
+ assert.ok(!payload.html.includes(" |