Skip to content

Stored XSS: unescaped lead fields rendered via dangerouslySetInnerHTML in email previews #3

Description

@StreamlinedStartup

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

  1. Upload a lead CSV with a field such as:
    firstName = <img src=x onerror=alert(document.domain)>
  2. Add the lead to a campaign so a queued message is generated (body uses {{firstName}}).
  3. Open the Emails view and preview that queued message.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions