Repository navigation
fix(email-marketing): escape the live send, sanitize previews, fail closed on missing preview secret - #51
Open
plsrd wants to merge 6 commits into
Open
fix(email-marketing): escape the live send, sanitize previews, fail closed on missing preview secret#51plsrd wants to merge 6 commits into
plsrd wants to merge 6 commits into
Conversation
…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
had a problem deploying
to
agentic-localization
September 3, 2026 20:09 — with
GitHub Actions
Error
plsrd
had a problem deploying
to
agentic-localization
September 3, 2026 20:09 — with
GitHub Actions
Failure
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This branch had an error being deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


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 whiledocs/SECURITY.mddescribed 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/clientso 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.tsbuilt the HTML that Klaviyo sends to real subscribers by droppingsubjectLine,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.@starter/render-email/escapewithescapeHtml(escapes& < > " ') andsafeHttpUrl(absolutehttp:/https:only;javascript:,data:, relative values are dropped with their element).@starter/render-email: workspace:*) and wraps every text and attribute interpolation; the giant product-cell one-liner becamerenderProductCellHtml.packages/render-email/src/index.tsnow uses the same helpers instead of its privateesc(), and applies the URL guard to logo/image/product/CTA URLs, so preview and send escape identically.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]returnedrenderPromotionKlaviyooutput (optionally round-tripped through Klaviyo's render API) to the browser with no sanitization, and the docs described DOMPurify, SSRF allow-listing, streaming pipelines, andisSafeUrl, none of which were wired up or existed.sanitizeEmailHtmlbefore building theX-Preview-Statusheader and responding (both the HTML and JSON variants).docs/SECURITY.mdrewritten 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 theemail-marketing-opsskill were corrected where they repeated the old claims.stubReplaceris a function, not a class;sanitizeStreamis gone).isSafeUrlwas invented; the URL rule lives insafeHttpUrlwhere URLs are actually interpolated.3. Replace the chunked streaming sanitizer (issue 3)
sanitizeStream.tscut the buffer atlastIndexOf('>')and sanitized each chunk independently, so<img src="x>y" onerror=...>split into two fragments that each looked harmless.sanitizeEmailHtml(html)inpackages/render-email/src/sanitize/index.ts: one DOMPurify pass over the whole document (WHOLE_DOCUMENT: true, forbidsscript/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.sanitize.test.tsinclude the chunk-boundary regression and a real MJML render../streamingkeepsrenderMjmlStream,stubReplacer,streamToString; only the sanitizer export was removed.4. Fail closed when the preview secret is missing in production (issue 4)
verifyPreviewSecretreturnedtruewheneverSANITY_PREVIEW_SECRETwas unset, so a deployment that forgot the variable had a public preview endpoint with no warning.'ok' | 'unauthorized' | 'misconfigured'. With the secret unset andNODE_ENV=productionorVERCEL_ENV=production, it logsSANITY_PREVIEW_SECRET is not set; refusing preview requests in productionand the route answers HTTP 500 viapreviewAuthErrorResponse. Outside production the dev convenience remains, with a one-timeconsole.warn.SANITY_PREVIEW_SECRETdocumented infrontend/.env.exampleand the README env table.5. Pin the frontend to the catalog
@sanity/client(found while verifying)The frontend had no direct
@sanity/clientdependency, so pnpm resolvednext-sanity's peer freely. After adding the workspace dependency tofunctions/, a fresh install linked the frontend to@sanity/client@8.5.0whilesanity.types.tsaugments the root 7.27.0, and every query result degraded tounknown(32 type errors). Declaring"@sanity/client": "catalog:"infrontend/package.jsonmakes the peer resolve to 7.27.0 like the root andfunctionsalready 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 forescapeHtml/safeHttpUrl, hostile content throughrenderPromotionKlaviyo, andsanitizeEmailHtmlincluding the chunk-boundary regression).pnpm lint: 0 errors, 9 warnings (all pre-existingno-empty-function).pnpm --filter @starter/render-email typecheck,pnpm --filter frontend typecheck: 0 errors.@starter/functionstypecheck has one pre-existing error inimport-klaviyo/index.ts(TS7022) that this PR does not touch;studioande2etypecheck also fail on pre-existing errors unrelated to these files.pnpm --filter @starter/functions build:on-promotion-approved2219 KB (was 2218 KB). It already exceeds the starter CI's 512 KB cap because ofklaviyo-api; this PR does not change that.pnpm run format:check(starter oxfmt) andnpx oxfmt --check email-marketingfrom the repo root: clean.next buildwith dummy env compiles the frontend under Turbopack;jsdomis in Next's default server externals, so DOMPurify loads server-side without config changes.<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).docs/TESTING.mdlists the manual checks.Notes for reviewers
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-secretis adopted. I did not put the secret in aSANITY_STUDIO_*variable because those are compiled into the public Studio bundle; this is called out indocs/SECURITY.md.KLAVIYO_WEBHOOK_SECRETis unset. Out of scope here; documented as recommended hardening..github/workflows/ci.ymlhas noemail-marketingjob, so none of these checks run on this repo's CI for this starter.🤖 Generated with Claude Code