## Security Review — FRE-580 Email Marketing Sequences **Reviewer:** Security Reviewer **Scope:** All 8 files in the email marketing implementation (services, tRPC routers, webhooks, templates, scheduler) **Verdict:** **2 P1, 4 P2, 4 P3** findings — assign back to Senior Engineer for fixes --- ### P1 — Critical (2 findings) **P1#1 Webhook Signature Validation Bypass** (`server/services/email-webhooks.ts:99-121`) When `RESEND_WEBHOOK_SECRET` is unset (common in dev/staging) OR the `x-signature` header is missing, the handler falls through to lines 117-121 which parse and process the payload with **zero signature verification**: ```ts const sigHeader = req.headers.get("x-signature"); if (sigHeader && process.env.RESEND_WEBHOOK_SECRET) { // ...signature validation... } // FALLTHROUGH: no validation when secret is missing const payload = await req.json(); ``` **Impact:** Any POST to the webhook endpoint is accepted. Attackers can forge delivery/open/click events to manipulate analytics, or forge `unsubscribed`/`bounced` events to alter send-log state. In production with the secret set, only missing-header attacks apply. **Fix:** Always validate signature. If secret is missing, return `503 Service Unavailable` rather than falling through to unvalidated processing. --- **P1#2 `sendTriggered` Open to All Authenticated Users with Unbounded Input** (`server/trpc/routers/email-marketing.ts:139-151`) ```ts sendTriggered: baseProcedure .use(requireAuth) // any authenticated user .input(z.object({ templateKey: z.string(), // any string, not enum-constrained variables: z.record(z.string()).optional(), // arbitrary key-value pairs })) ``` Any authenticated user can trigger **any** template in the registry with arbitrary variables. This means: - A free-tier user can fire `conversion:trial_ending` with `price: "$0.01"` and receive a misleading upgrade email - Variables like `{{price}}`, `{{feature_name}}`, `{{winback_code}}` render directly into HTML without escaping — stored XSS vector if email HTML is later displayed in admin UI **Impact:** Unbounded email sending (Resend quota exhaustion), HTML injection via template variables, template abuse (users triggering internal/Pro-only templates). **Fix:** Either (a) add `requireAdmin` middleware, or (b) constrain `templateKey` to an enum of user-allowed templates and add server-side variable allowlists per template. --- ### P2 — High (4 findings) **P2#3 HTML Injection via Template Variables** (`server/services/email-templates.ts` + `email-marketing.ts:146`) Template variables `{{price}}`, `{{feature_name}}`, `{{trial_price}}`, `{{winback_code}}` are interpolated directly into HTML bodies. Combined with P1#2 (arbitrary user-supplied variables), this creates a stored XSS vector: ```html
Only {{price}}/month
``` If `price` = ``, the rendered HTML contains executable script. If email content is stored and later rendered in an admin dashboard or analytics view, this becomes persistent XSS. **Fix:** HTML-escape all user-supplied template variables before interpolation, or use a templating library with auto-escaping (e.g., Handlebars). --- **P2#4 Empty Email Enrollment Still Possible** (`server/trpc/routers/email-marketing.ts:113-117`) ```ts const [user] = await db.select({ email: users.email }).from(users).where(eq(users.id, userId)); const email = user?.email || ""; // fallback to empty string await emailSequenceService.enrollUser(userId, input.sequenceKey, email); ``` When `userId` parses correctly but no user row exists (e.g., deleted user, race condition), `email` is `""`. The schema allows `text("email").notNull()` — empty string passes validation. The enrollment is created with an empty email, causing silent send failures. **Fix:** Return error when user not found, or re-fetch email from `users` table at send time (don't cache in enrollment). --- **P2#5 Analytics Memory Exhaustion** (`server/services/email-sequence-service.ts:473`) ```ts const allEmails = await db.select().from(emailSendLog).where(buildWhere()); for (const email of allEmails) { ... } ``` The `getAnalytics` query loads **all** email send log records into memory to compute `bySequence` breakdowns. With no row limit or pagination, a large send log (100K+ rows) can exhaust process memory. **Fix:** Use SQL `GROUP BY` aggregation instead of in-memory iteration, or add a `LIMIT` clause with a warning when truncated. --- **P2#6 `getOptInField` Undefined Cast on Unknown Keys** (`server/services/email-sequence-service.ts:543-553`) ```ts const map: Record