Skip to content

fix(preview): route the preview through the shared sanitize contract - #384

Merged
PathGao merged 1 commit into
sftwrdotdev:masterfrom
PathGao:fix/preview-shared-sanitize
Aug 2, 2026
Merged

fix(preview): route the preview through the shared sanitize contract#384
PathGao merged 1 commit into
sftwrdotdev:masterfrom
PathGao:fix/preview-shared-sanitize

Conversation

@PathGao

@PathGao PathGao commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

The gap

#376 introduced src/lib/utils/sanitize.ts as the single markdown sanitize policy. The comment on its FORBID_TAGS: ['style'] entry argues the preview case specifically:

In the preview the sanitized HTML is injected into the app's own document… it can hide the title bar or turn any selector into an outbound beacon.

Only export.ts ever imported it. The preview kept its own copy of the URI regexp and passed ALLOWED_URI_REGEXP alone — so the one tag that comment was written about was the one the preview did not forbid. Providing the correct path is not the same as closing the wrong one.

Measured, not argued

Real DOMPurify (the pinned 3.4.12 build), the real processMarkdownHtml from this branch, injected into a live document containing a .titlebar, in the preview's real order:

config <style> .titlebar computed display body computed background-image
{ALLOWED_URI_REGEXP} — before kept verbatim, CSS unfiltered none url("https://attacker.example/beacon")
MARKDOWN_SANITIZE_CONFIG — after removed flex none

The beacon is not hypothetical: after injection, performance.getEntriesByType('resource') lists the outbound request. Both halves are permitted by the shipped CSP (style-src 'self' 'unsafe-inline' …, img-src 'self' asset: https: …).

The fix

Import sanitizeMarkdownHtml; delete the local markdownLinkExtensions / markdownLinkExtensionPattern / allowedMarkdownUriPattern. sanitize.ts is unchanged — the shared config was already right.

The deleted regexp was verified byte-identical to the shared one (String(local) === String(shared)true), so no URI behaviour changes. The only behavioural delta is that an author's <style> is dropped.

The order difference is deliberate now, not accidental

git log -S "import DOMPurify" shows the preview's sanitize call arrived in eb9a1c7 (Mermaid foreignObject support), bolted onto the render sink where the processed HTML was already cached. The ordering was never a decision.

But the resulting order is the right one for this sink, and it is kept:

  • Export sanitizes first because its bytes leave the app and are read by another program; running the filter over Markpad's own generated markup would let a future policy tightening silently delete part of the export. It has a CSP as second line of defence, and exportSanitize.test.ts pins that order.
  • Preview sanitizes last because it has no second line — its output goes straight into the live application document. The string that reaches {@html} must be the sanitizer's output, with no parse/serialize round trip after the filter ran.

Both rationales are now in a comment at the call site, and a test fails if the preview call is moved ahead of processing, so the asymmetry cannot be "tidied up" later.

The render pipeline was verified, not assumed

Measured in a browser, on a fixture with headings/folds, task items, a folded callout, <img style="width:50%">, video replacement, a YouTube thumbnail, a table, code blocks and inline + display math:

  • processMarkdownHtml output contains no <style>; the fixture sanitizes byte-identically under the old and new configs.
  • KaTeX and highlight.js output contain no <style> (KaTeX's CSS is a real imported stylesheet).
  • Inline style attributes still survive; onerror is still dropped.
  • Mermaid's SVG does contain a <style> block#id-scoped fonts, stroke widths, dash patterns. It is unaffected: mermaid runs on the live DOM after {@html sanitizedHtml}, through the separate sanitizeDiagramSvg, which needs foreignObject (the document policy forbids it) and needs that inline <style> (the document policy forbids it too — applying the shared config to a diagram flattens its fonts and edge styling, measured). The two policies must stay separate, and a test now asserts the split so a future "let's unify these" has to read why.

Alignment with other editors

  • Typora, verbatim: "Scripts are basically not allowed. <style> and <meta> won't be applied either."
  • Obsidian sanitizes note HTML with DOMPurify; styling goes through themes and CSS snippets, not document <style>.
  • VS Code reaches the same end by isolation instead of stripping — its preview is a webview iframe on a separate origin with default-src 'none', so document CSS physically cannot reach the editor chrome. Markpad injects into its own document, so stripping is the equivalent available here.

Tests

scripts/previewSanitize.test.ts (new). DOMPurify cannot run under node --test (no DOM, and the repo deliberately has no test-only DOM dependency — the shim from #378 is scoped to processMarkdownHtml and should not grow into a general parser), so the test pins the wiring chain the way exportSanitize.test.ts does and records the browser measurements verbatim in its header.

It also adds a call-site allowlist for DOMPurify.sanitize( across src/ — the durable form of this bug, since no compiler can see a new private policy appearing.

Preview reverted to master, test kept 3 failed / 2 passed
Fixed 5 passed / 0 failed
Full suite npm test 364 / 364, npm run check 0 errors 0 warnings

Not covered

  • Not verified inside the running Tauri app. The evidence is real DOMPurify + real processMarkdownHtml + real live-DOM computed styles + a real outbound request, plus the CSP read from tauri.conf.json; I did not launch tauri dev.
  • Adjacent surface not audited: mermaid's <style> is emitted from user-controlled diagram source, so a CSS breakout via classDef/style directives is conceivable. One attempt was rejected by mermaid's own lexer, but one probe is not an audit. Worth a separate look.
  • Unrelated environment note surfaced while measuring: npm ls dompurify reports the installed tree as invalid (3.3.1 present despite the 3.4.12 pin/override). All numbers above were re-measured against a freshly fetched 3.4.12. Worth checking separately; this PR does not touch it.

🤖 Generated with Claude Code

sftwrdotdev#376 introduced `src/lib/utils/sanitize.ts` as the single markdown
sanitize policy, and the comment on its `FORBID_TAGS: ['style']` entry
argues the preview case specifically: the sanitized HTML is injected
into the app's own document, where a document `<style>` can hide the
title bar or turn any selector into an outbound beacon.

Only the export path ever imported it. The preview kept a local copy of
the URI regexp and passed `ALLOWED_URI_REGEXP` alone, so `<style>` -
which DOMPurify allows by default, CSS unfiltered - reached the live
document. Measured in a browser against the pinned DOMPurify build: the
title bar's computed `display` became `none` and the outbound request
for the beacon appears in `performance.getEntriesByType('resource')`.
The app CSP permits both halves.

The deleted regexp was byte-identical to the shared one, so no URI
behaviour changes; the only delta is that author `<style>` is dropped.

The order difference between the two sinks is left alone and now
documented: export sanitizes first because its bytes leave the app and
filtering Markpad's own generated markup could silently delete part of
the export; the preview sanitizes last because it has no second line of
defence, so the string reaching `{@html}` must BE the sanitizer's
output with no round trip after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao merged commit 1af1e35 into sftwrdotdev:master Aug 2, 2026
4 checks passed
@PathGao
PathGao deleted the fix/preview-shared-sanitize branch August 2, 2026 21:14
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