Files
vue-journal-layer/docs/decisions/0001-sanitize-html.md
Jakub Zych a6ee443885 feat: keep Blog Editor markup in the sanitizer and expand Journal embed placeholders into privacy-enhanced iframes
- htmlGate.ts mirrors the four editor-output rules of the Go policy:
  figure data-embed (^youtube$) and data-id (^[A-Za-z0-9_-]{11}$),
  img alt with any text, ol start (integer)
- embeds.ts: EMBED_PROVIDERS (youtube on youtube-nocookie.com),
  EMBED_PLACEHOLDER and expandEmbeds
- JournalPostBody and JournalCategoryHeader render
  expandEmbeds(sanitizeJournalHtml(html)); payload transforms only sanitize
- .journal-embed styles (16 / 9 block, borderless iframe)
- tests: byte copy of the editor corpus, mirror and pipeline cases;
  acme hello-acme carries a placeholder and the SSR smoke checks the iframe
- README Embeds section and decision note Policy amendment
2026-10-10 12:19:00 +02:00

8.7 KiB

Decision: sanitize-html sanitizes Journal HTML in the layer

Date: 2026-10-09 Status: Accepted Supersedes: the three-regex gate in app/utils/htmlGate.ts (UNSAFE_TAG, EVENT_HANDLER, DANGEROUS_URL)

Decision

The layer depends on sanitize-html 2.18.0 (Apostrophe, MIT; released 2026-09-30, the latest version) and on @types/sanitize-html 2.16.2 for development. app/utils/htmlGate.ts exports sanitizeJournalHtml(html): string. Every v-html sink and every payload transform passes Journal HTML through it: JournalPostBody, JournalCategoryHeader, toPostView (now in app/utils/postView.ts) and the category page.

The exported names stay. gateJournalHtml(html) still returns { safe, html }, but html is now always the sanitized string and is never blanked. safe (and isSafeJournalHtml) report whether the policy kept the input whole. The blocked props, the content_blocked view field, the journal.post.contentUnavailable string and the .journal-post-body--blocked style are removed, because nothing is blocked any more.

Policy

The policy mirrors the Journal plugin's bluemonday policy (classes/format_html.go, decision note journal-html-sanitizing-bluemonday.md in the framework repository):

  • Elements: bluemonday's UGCPolicy set plus footer. These are the sectioning, heading, grouping, phrasing, list, table, img, map/area, meter and progress elements. script, style, iframe, object, embed, form, input, base, meta, link, svg, math and comments are removed. The text of a removed element is kept, except inside script, style, textarea, option, noscript, title, iframe, noembed, noframes, object, xmp and template.
  • Attributes: UGC's attributes with UGC's value patterns, ported from helpers.go (dir, lang, id, title globally; href, src, cite, alt, table and list attributes per element). The additions are class, itemscope, itemtype (https://schema.org/<Type>) and itemprop globally; role = doc-noteref, doc-endnotes or doc-backlink on a and div; dir = ltr, rtl or auto; and data-message-author-role and data-message-id on div. An attribute whose value fails its pattern is removed. Every on* attribute is removed, as are target and rel on a.
  • Blog Editor output (Phase 16.2, amended 2026-10-10): four rules for the Journal plugin's Blog Editor, the same four its Go policy gained: data-embed = youtube and data-id matching ^[A-Za-z0-9_-]{11}$ on figure (the embed placeholder <figure data-embed="youtube" data-id="ID"></figure>, which app/utils/embeds.ts expands into a youtube-nocookie iframe after sanitizing), alt with any text on img (the editor's free-text alt field; UGC's pattern cuts it at :, ? and ", and sanitize-html escapes the value), and start as an integer on ol (lists that do not begin at 1). They are the editor's own output, pinned by tests/fixtures/editor-corpus.json, a byte copy of the editor's corpus that the Go policy test reads too: every entry is safe here. No other data attribute, no iframe, no new URL scheme and no new style is allowed, and alt on area keeps UGC's pattern.
  • Inline styles: only color, font-size, font-weight and text-align, with the values that bluemonday's CSS handlers accept. A named colour must be letters only, so url() and expression() never pass.
  • URLs in href, src and cite must be http, https, mailto, relative or protocol-relative, and must parse as a URL. The scheme is read after entity decoding and after removing the characters a browser ignores (ASCII control characters, space and DEL). javascript&#58;, &#106;avascript: and java<TAB>script: are all refused.

Sanitize, don't blank. A value is never refused as a whole. Text such as "Data: 12 maja 2024" or "metadata:" is ordinary text, not a URL, so it always renders.

The output is always the sanitizer's own serialization, never the input bytes. The Go side returns the input unchanged when the policy keeps it whole, which is safe there because golang.org/x/net/html tokenizes the way a browser does. htmlparser2 is not a spec parser, so returning the input could hand the browser markup it reads differently from how the policy read it. The browser only ever parses what the policy checked.

Why sanitize-html

sanitize-html 2.18.0 isomorphic-dompurify 4.5.0 (DOMPurify 3.4.16 + jsdom 30.1.2)
Server (Nitro) Pure JavaScript (htmlparser2, postcss). No DOM. Needs jsdom: about 5.5 MB unpacked plus its dependency tree, and a full window object in the Nitro process. jsdom 30 and isomorphic-dompurify 4.5 require Node ^22.22.2, but .nvmrc pins 22.22.0.
Server/client parity The same parser and serializer run on both sides, so the same input gives the same string (checked in Chromium: the hydrated SSR body and the body after client-side navigation have identical innerHTML). jsdom parses on the server and the browser's DOM on the client. The two can serialize edge cases and CSS differently (jsdom's cssstyle and the browser's CSSOM normalize colours differently).
Policy fit Declarative: per-element attribute lists, allowedStyles with a regex per CSS property, allowedSchemes, and transformTags to check attribute values. It maps onto bluemonday rule by rule. No per-attribute value patterns and no CSS property filter. Mirroring the server policy would need hooks that the project writes and owns (uponSanitizeAttribute plus a hand-written style filter), which is exactly the security code the project prefers to leave to a library.
Client bundle About 157 KB minified, 59 KB gzipped in the playground build. It loads only on the post and category pages (see below). About 30 KB minified, 11 KB gzipped (DOMPurify alone).
Maintenance Apostrophe; a release almost every month in 2026 (2.17.1 in February to 2.18.0 in September), including recent fixes such as the meta refresh URL check (GHSA-cv27-6wvh-8x7j). Cure53 (DOMPurify) plus a single maintainer for the isomorphic wrapper.

DOMPurify is the stronger choice for a browser-only sanitizer, and it costs about a fifth of the client bytes. For this layer, every page is rendered on the server first, so the server side decides. There, jsdom is a heavy runtime dependency that conflicts with the pinned Node version, and two different DOMs would sanitize the same body differently. sanitize-html is the same code everywhere and expresses the server policy directly.

Bundle cost, contained. toPostView lives in app/utils/postView.ts, apart from view.ts. List pages, cards and an app homepage that use toListPost or formatJournalDate therefore do not load the sanitizer. In the playground build, the chunk with sanitize-html is a dependency of PostPage and CategoryPage only. Most of its size is postcss (about 56 KB minified), which sanitize-html uses to parse style attributes.

Alternatives considered

  • isomorphic-dompurify: see the table above.
  • DOMPurify on the client and sanitize-html on the server: this means two policies to keep in step and two different outputs for the same body. Rejected.
  • xss (js-xss) 1.0.15: small, but it has one maintainer, its tag-attribute whitelist cannot express per-value patterns without callbacks we would write, and its CSS filter is a separate package. Rejected for the same reason as hand-built hooks.
  • Keep the regex gate and anchor it to attribute values: this fixes the false refusal of "Data:" text but none of the bypasses. Rejected.
  • Sanitize only on the server and proxy client-side API calls through Nitro: this saves the client bytes, but it changes how client navigation reaches the API (today it goes straight to /_journal/api/v1). Worth revisiting if the 59 KB matters. It needs its own decision.

Consequences

  • An unsafe body now renders without its unsafe parts and with all of its safe text. The smoke expectation for /blog/unsafe-sample checks for <p>Before</p><p>After</p> instead of the blocked notice.
  • Content that relied on an embedded <style> block loses it, exactly as it does on the server's next save. Its CSS belongs in the app, scoped under .journal-post-body (the imported author footer of the first consuming application is an example).
  • An app that still renders gateJournalHtml(...).html keeps working and now gets sanitized HTML. An app test that expects journal-post-body--blocked or the journal.post.contentUnavailable string has to change when the app moves to this version.
  • isSafeJournalHtml compares the policy's output with the same serializer's output when nothing is refused. Comments are dropped by both, so a comment alone does not make a value unsafe.