fix(sanitize): allow trusted-host iframe embeds in post/page body content - #369
fix(sanitize): allow trusted-host iframe embeds in post/page body content#369asachs01 wants to merge 2 commits into
Conversation
…tent base.outlet's `html` prop (the markdown-rendered entry body every post uses) was declared type `richtext`, so escapeProps() ran it through DOMPurify's RICHTEXT_CONFIG — an allowlist built for short-form fields (p/strong/em/a/ul/li) with no img, video, table, or iframe. marked passes raw HTML blocks through untouched, so a pasted YouTube <iframe> survives markdown parsing fine and is then silently stripped at this later sanitize step. Reported by WYRE (2 posts with embedded YouTube videos not rendering). Adds a new `richtextBody` control type, used only by base.outlet's `html` prop, sanitized via a new POST_BODY_CONFIG: the same safe formatting tags as RICHTEXT_CONFIG plus img/video/table (what a real post body markdown-renders to) plus iframe — scoped to a trusted-host allowlist (youtube.com, youtube-nocookie.com, subdomain-aware) via a DOMPurify uponSanitizeElement hook that removes the whole element (not just the src) on any non-match, including lookalike-host attempts (youtube.com.evil.com, evilyoutube.com — tested). Every other richtext-typed field in the CMS is unaffected — kept as a distinct control type rather than widening RICHTEXT_CONFIG globally, so short-form fields elsewhere don't gain a wider attack surface than they need. Full suite: 6611 tests passing (0 fail), tsc clean, lint clean.
|
Tried this against a Webflow migration where the post bodies are HTML, and The config allows <video controls poster="/uploads/thumb.avif">
<source src="/uploads/clip.mp4" type="video/mp4">
</video>publishes as a bare Suggest adding Separately, and maybe worth a line in this PR's description: with the current narrow Context: found while doing an AI-assisted migration of a ~26-post site from Webflow into Instatic, so the findings come from that codebase rather than a hand-written repro. |
…t body POST_BODY_CONFIG allowed `video` but not `source`, `poster`, `type`, `playsinline`, `loop`, `muted`, or `preload` — the form real editors and importers (Webflow among them) actually emit is `<video controls poster=…><source src=… type=…></video>`, not `<video src=…>`. Without `source` in ALLOWED_TAGS the child was dropped entirely, publishing a bare, unplayable `<video controls>`. Also allow `figure`/`figcaption`, which rich-text editors commonly wrap images in. Found via PR review on CoreBunch#369 while testing against a real Webflow migration.
|
Good catch, thanks for testing against a real migration — that's exactly the kind of gap a hand-written repro would've missed. Pushed a fix (d55c33a): added Also added a line to the PR description documenting the happy-dom/DOMPurify traversal quirk you flagged (the "one dropped image per post" symptom under the old narrow config) — useful context for anyone bisecting the old behavior later. |
Summary
base.outlet'shtmlprop (the markdown-rendered post/page body) is typedrichtext, soescapeProps()sanitizes it throughRICHTEXT_CONFIG— an allowlist built for short marketing copy with noimg/video/table/iframe.markedpasses raw HTML blocks through untouched during markdown parsing, so a pasted YouTube<iframe>(or any other embed) survives parsing and then gets silently stripped at the later sanitize step — with no error or warning surfaced to the author.Confirmed there's no existing zero-code path around this: the Tiptap post-body editor has no video/embed insertion feature ("Add Media" opens the media-library file picker, not a URL-embed), and
base.video(the module that could otherwise host a YouTube embed) is page-builder-canvas-only — it has no bridge into markdown post-body fields.Change
richtextBodycontrol type, distinct fromrichtext, used only bybase.outlet.POST_BODY_CONFIGDOMPurify config: allowsimg/video/source/figure/figcaption/table/iframe, withiframescoped to a trusted-host allowlist (youtube.com,youtube-nocookie.com, subdomain-aware) via anuponSanitizeElementhook.youtube.com.evil.com,evilyoutube.comboth correctly rejected) to guard the allowlist logic itself.richtext-typed field (short-copy fields elsewhere in the module system) is unaffected — this only changes the config used bybase.outlet's body prop.Update (per review): the initial allowlist let
videothrough but notsource, so the common editor/importer output —<video controls poster="…"><source src="…" type="video/mp4"></video>— published as a bare, unplayable<video controls>. AddedsourcetoALLOWED_TAGSandposter/type/playsinline/loop/muted/preloadtoALLOWED_ATTR, plusfigure/figcaptionsince that's what rich-text editors wrap images in.That review also flagged a pre-existing DOMPurify/happy-dom quirk worth documenting here for anyone bisecting old behavior: under the narrower
RICHTEXT_CONFIG(noimginALLOWED_TAGS), DOMPurify's element traversal on happy-dom skips the node immediately after each removal — so exactly one<img>per document was silently dropped and the rest survived. That's why the old symptom read as an isolated rendering glitch (one missing picture per post) rather than "images are stripped from bodies entirely." This PR's widerPOST_BODY_CONFIGallowlistsimgoutright, so the traversal quirk no longer has anything to trigger it onbase.outletbodies — no separate fix needed, just calling it out so the history makes sense.Test plan
ExportDialogfetch-mock test observed and reproduced as flaky on rerun)tsccleaneslintclean<video poster>+<source type>,playsinline/loop/muted/preload, and<figure>/<figcaption>preservation.Happy to adjust the trusted-host list or expose it as a config option if that's preferred upstream — went with a hardcoded YouTube-only allowlist since that's the immediate need, but a configurable allowlist would be a small follow-up if there's appetite for embeds from other providers.