Summary
Lead-provided fields (imported from CSV) are interpolated unescaped into email-body HTML that is then rendered with dangerouslySetInnerHTML. A crafted lead field executes JavaScript in the operator's browser when they preview a message — a stored/persistent XSS reachable purely by uploading a lead list.
This is pre-existing (it predates the opt-out-footer work in #2) and lives in the shared preview render path, so filing separately.
Where
- Render sink:
apps/web/app/(dashboard)/emails/emails-view.tsx renders the message body with dangerouslySetInnerHTML={{ __html: email.body }}.
- Tainted source:
apps/web/app/(dashboard)/emails/page.tsx builds queued/scheduled bodies with renderVariables(templateBody, vars), where vars includes lead fields (firstName, lastName, company, openingLine, and arbitrary customFields) straight from CSV import — no escaping between interpolation and the HTML sink.
The sequence editor Live preview renders through a React text node (<pre>{...}</pre>), so React escapes it — that path is safe. The Emails preview is the exposed one.
Repro
- Upload a lead CSV with a field such as:
firstName = <img src=x onerror=alert(document.domain)>
- Add the lead to a campaign so a queued message is generated (body uses
{{firstName}}).
- Open the Emails view and preview that queued message.
- The payload executes in the operator's browser.
Impact
Single-user self-hosted app, so no cross-tenant boundary — but the operator's session is still fully scriptable by anyone who can get a lead row in (e.g. a purchased/shared list, or a reply that auto-creates a lead). Cookies, the encryption-key status page, and authenticated server actions are all reachable from that context.
Suggested fix
Escape variable output before it reaches dangerouslySetInnerHTML, or sanitize the composed HTML (e.g. a small allowlist sanitizer) at the preview boundary. Since the template body itself may legitimately contain HTML, the safest split is: HTML-escape interpolated variable values inside renderVariables output used for HTML contexts, while leaving author-authored template markup intact. The same escaping should apply anywhere renderVariables output is sent to an HTML sink.
Notes
Reported during a review of #2 (opt-out footer). The footer text itself is already escaped in appendUnsubscribeFooter; this issue is specifically about lead-variable interpolation.
Summary
Lead-provided fields (imported from CSV) are interpolated unescaped into email-body HTML that is then rendered with
dangerouslySetInnerHTML. A crafted lead field executes JavaScript in the operator's browser when they preview a message — a stored/persistent XSS reachable purely by uploading a lead list.This is pre-existing (it predates the opt-out-footer work in #2) and lives in the shared preview render path, so filing separately.
Where
apps/web/app/(dashboard)/emails/emails-view.tsxrenders the message body withdangerouslySetInnerHTML={{ __html: email.body }}.apps/web/app/(dashboard)/emails/page.tsxbuilds queued/scheduled bodies withrenderVariables(templateBody, vars), wherevarsincludes lead fields (firstName,lastName,company,openingLine, and arbitrarycustomFields) straight from CSV import — no escaping between interpolation and the HTML sink.The sequence editor Live preview renders through a React text node (
<pre>{...}</pre>), so React escapes it — that path is safe. The Emails preview is the exposed one.Repro
firstName = <img src=x onerror=alert(document.domain)>{{firstName}}).Impact
Single-user self-hosted app, so no cross-tenant boundary — but the operator's session is still fully scriptable by anyone who can get a lead row in (e.g. a purchased/shared list, or a reply that auto-creates a lead). Cookies, the encryption-key status page, and authenticated server actions are all reachable from that context.
Suggested fix
Escape variable output before it reaches
dangerouslySetInnerHTML, or sanitize the composed HTML (e.g. a small allowlist sanitizer) at the preview boundary. Since the template body itself may legitimately contain HTML, the safest split is: HTML-escape interpolated variable values insiderenderVariablesoutput used for HTML contexts, while leaving author-authored template markup intact. The same escaping should apply anywhererenderVariablesoutput is sent to an HTML sink.Notes
Reported during a review of #2 (opt-out footer). The footer text itself is already escaped in
appendUnsubscribeFooter; this issue is specifically about lead-variable interpolation.