Skip to content

fix(email-marketing): escape the live send, sanitize previews, fail closed on missing preview secret - #51

Open
plsrd wants to merge 6 commits into
mainfrom
fix/email-marketing-review-bugs
Open

plsrd wants to merge 6 commits into
mainfrom
fix/email-marketing-review-bugs

Conversation

@plsrd

@plsrd plsrd commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Fixes the four reviewer-reported issues in email-marketing/: the live Klaviyo send interpolated authored fields into HTML with no escaping, the preview route served unsanitized HTML while docs/SECURITY.md described a seven-layer defense that did not exist, the (dead) streaming sanitizer split payloads mid-tag, and the preview secret check fell open whenever the env var was unset. It also pins the frontend to the catalog @sanity/client so the typegen query augmentation resolves deterministically, which the first fix exposed.

Changes

1. Escape every interpolated field in the live send (issue 2)

functions/on-promotion-approved/index.ts built the HTML that Klaviyo sends to real subscribers by dropping subjectLine, disruptor, headline, body, legalText, product titles, and image/CTA URLs straight into template literals. Anything HTML-like in those fields (from Studio, the API, or AI generation) shipped verbatim.

  • New dependency-free subpath @starter/render-email/escape with escapeHtml (escapes & < > " ') and safeHttpUrl (absolute http:/https: only; javascript:, data:, relative values are dropped with their element).
  • The Function bundles those helpers (@starter/render-email: workspace:*) and wraps every text and attribute interpolation; the giant product-cell one-liner became renderProductCellHtml.
  • The MJML renderer in packages/render-email/src/index.ts now uses the same helpers instead of its private esc(), and applies the URL guard to logo/image/product/CTA URLs, so preview and send escape identically.
  • Why not the whole renderer: the Function bundle is already 2218 KB because of klaviyo-api, and bundling mjml adds ~1.9 MB more. Sharing the escape helpers keeps the bundle at 2219 KB.

2. Sanitize previews and make the security docs truthful (issue 1)

/api/preview/klaviyo/[id] returned renderPromotionKlaviyo output (optionally round-tripped through Klaviyo's render API) to the browser with no sanitization, and the docs described DOMPurify, SSRF allow-listing, streaming pipelines, and isSafeUrl, none of which were wired up or existed.

  • The route now runs the final HTML through sanitizeEmailHtml before building the X-Preview-Status header and responding (both the HTML and JSON variants).
  • docs/SECURITY.md rewritten to describe only what the code does (escaping, whole-document sanitization, secret auth, response headers, GROQ parameterization, webhook HMAC) with a clearly labelled "Not implemented" list (per-link tokens, Studio OAuth, SSRF allow-list, rate limiting, audit logging). README, docs/ARCHITECTURE.md, docs/TESTING.md, AGENT.md, and the email-marketing-ops skill were corrected where they repeated the old claims.
  • The package doc comment's usage example now matches the real exports (stubReplacer is a function, not a class; sanitizeStream is gone).
  • No isSafeUrl was invented; the URL rule lives in safeHttpUrl where URLs are actually interpolated.

3. Replace the chunked streaming sanitizer (issue 3)

sanitizeStream.ts cut the buffer at lastIndexOf('>') and sanitized each chunk independently, so <img src="x>y" onerror=...> split into two fragments that each looked harmless.

  • Removed in favour of sanitizeEmailHtml(html) in packages/render-email/src/sanitize/index.ts: one DOMPurify pass over the whole document (WHOLE_DOCUMENT: true, forbids script/iframe/object/embed/form/input/textarea/select/button/base/link), keeps the <html>/<head>/<style> scaffolding MJML emits and Klaviyo Handlebars tokens, re-adds the doctype DOMPurify drops.
  • Tests in sanitize.test.ts include the chunk-boundary regression and a real MJML render.
  • ./streaming keeps renderMjmlStream, stubReplacer, streamToString; only the sanitizer export was removed.

4. Fail closed when the preview secret is missing in production (issue 4)

verifyPreviewSecret returned true whenever SANITY_PREVIEW_SECRET was unset, so a deployment that forgot the variable had a public preview endpoint with no warning.

  • It now returns 'ok' | 'unauthorized' | 'misconfigured'. With the secret unset and NODE_ENV=production or VERCEL_ENV=production, it logs SANITY_PREVIEW_SECRET is not set; refusing preview requests in production and the route answers HTTP 500 via previewAuthErrorResponse. Outside production the dev convenience remains, with a one-time console.warn.
  • SANITY_PREVIEW_SECRET documented in frontend/.env.example and the README env table.

5. Pin the frontend to the catalog @sanity/client (found while verifying)

The frontend had no direct @sanity/client dependency, so pnpm resolved next-sanity's peer freely. After adding the workspace dependency to functions/, a fresh install linked the frontend to @sanity/client@8.5.0 while sanity.types.ts augments the root 7.27.0, and every query result degraded to unknown (32 type errors). Declaring "@sanity/client": "catalog:" in frontend/package.json makes the peer resolve to 7.27.0 like the root and functions already do. The lockfile is gitignored, so without this the outcome depends on the day's resolution.

Testing

  • pnpm test: 59 passed (was 41; 18 new tests for escapeHtml/safeHttpUrl, hostile content through renderPromotionKlaviyo, and sanitizeEmailHtml including the chunk-boundary regression).
  • pnpm lint: 0 errors, 9 warnings (all pre-existing no-empty-function).
  • pnpm --filter @starter/render-email typecheck, pnpm --filter frontend typecheck: 0 errors. @starter/functions typecheck has one pre-existing error in import-klaviyo/index.ts (TS7022) that this PR does not touch; studio and e2e typecheck also fail on pre-existing errors unrelated to these files.
  • pnpm --filter @starter/functions build: on-promotion-approved 2219 KB (was 2218 KB). It already exceeds the starter CI's 512 KB cap because of klaviyo-api; this PR does not change that.
  • pnpm run format:check (starter oxfmt) and npx oxfmt --check email-marketing from the repo root: clean.
  • next build with dummy env compiles the frontend under Turbopack; jsdom is in Next's default server externals, so DOMPurify loads server-side without config changes.
  • Probed DOMPurify against real MJML output before choosing the config: keeps <html>/<head>/<style>, tables, bgcolor, {{ unsubscribe_url }}; strips <script>, onerror, javascript:, and Outlook conditional comments (irrelevant for a browser preview; the send path is not sanitized).
  • Not verified live: an actual Klaviyo send, the preview route against a real dataset, and the production 500 path. docs/TESTING.md lists the manual checks.

Notes for reviewers

  • The Studio's "Open preview" links (sanity.config.ts, CampaignGridView.tsx) carry no token. Once the secret is set, as production now requires, they return 401 until the secret is appended or @sanity/preview-url-secret is adopted. I did not put the secret in a SANITY_STUDIO_* variable because those are compiled into the public Studio bundle; this is called out in docs/SECURITY.md.
  • The engagement webhook has the same fail-open pattern when KLAVIYO_WEBHOOK_SECRET is unset. Out of scope here; documented as recommended hardening.
  • The root monorepo .github/workflows/ci.yml has no email-marketing job, so none of these checks run on this repo's CI for this starter.

🤖 Generated with Claude Code

plsrd and others added 6 commits September 3, 2026 12:33
…iyo send

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…so typegen augmentation resolves

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…chunking at >

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…anitizer

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…in production

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ly implements

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@plsrd
plsrd deployed to ai-shopping-assistant September 3, 2026 20:09 — with GitHub Actions Active
@plsrd
plsrd deployed to commerce-plp-management September 3, 2026 20:09 — with GitHub Actions Active
@plsrd
plsrd deployed to knowledge-base September 3, 2026 20:09 — with GitHub Actions Active
@plsrd
plsrd deployed to commerce-pdp-management September 3, 2026 20:09 — with GitHub Actions Active
@plsrd
plsrd deployed to commerce-plp-management September 3, 2026 20:09 — with GitHub Actions Active
@plsrd
plsrd had a problem deploying to agentic-localization September 3, 2026 20:09 — with GitHub Actions Error
@plsrd
plsrd deployed to commerce-pdp-management September 3, 2026 20:09 — with GitHub Actions Active
@plsrd
plsrd had a problem deploying to agentic-localization September 3, 2026 20:09 — with GitHub Actions Failure
@plsrd
plsrd deployed to knowledge-base September 3, 2026 20:09 — with GitHub Actions Active
@vercel

vercel Bot commented Sep 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
agentic-localization Ready Ready Preview Sep 3, 2026 8:10pm UTC
ai-shopping-assistant Ready Ready Preview Sep 3, 2026 8:10pm UTC
commerce-pdp-starter Ready Ready Preview Sep 3, 2026 8:10pm UTC
commerce-plp-starter Ready Ready Preview Sep 3, 2026 8:10pm UTC
content-analytics-starter Ready Ready Preview Sep 3, 2026 8:10pm UTC

Request Review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. Cursor Bugbot was not running on this PR, no approval policy required human review, and reviewers were not assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Approver

This branch had an error being deployed

1 failed and 9 active deployments
Preview – ai-shopping-assistant — 566aff66 Deployed Sep 3, 2026 by vercel[bot]
Preview – content-analytics-starter — 566aff66 Deployed Sep 3, 2026 by vercel[bot]
ai-shopping-assistant — 566aff66 Deployed Sep 3, 2026 by plsrd via ai-shopping-assistant #197
knowledge-base — 566aff66 Deployed Sep 3, 2026 by plsrd via knowledge-base (Node 22) #197
commerce-plp-management — 566aff66 Deployed Sep 3, 2026 by plsrd via commerce-plp-management (Node 20) #197
commerce-pdp-management — 566aff66 Deployed Sep 3, 2026 by plsrd via commerce-pdp-management (Node 20) #197
agentic-localization — 566aff66 Deployed Sep 3, 2026 by plsrd via agentic-localization (Node 22) #197
Preview – commerce-pdp-starter — 566aff66 Deployed Sep 3, 2026 by vercel[bot]
Preview – commerce-plp-starter — 566aff66 Deployed Sep 3, 2026 by vercel[bot]
Preview – agentic-localization — 566aff66 Deployed Sep 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant