- 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
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
UGCPolicyset plusfooter. These are the sectioning, heading, grouping, phrasing, list, table,img,map/area,meterandprogresselements.script,style,iframe,object,embed,form,input,base,meta,link,svg,mathand comments are removed. The text of a removed element is kept, except insidescript,style,textarea,option,noscript,title,iframe,noembed,noframes,object,xmpandtemplate. - Attributes: UGC's attributes with UGC's value patterns, ported from
helpers.go(dir,lang,id,titleglobally;href,src,cite,alt, table and list attributes per element). The additions areclass,itemscope,itemtype(https://schema.org/<Type>) anditempropglobally;role=doc-noteref,doc-endnotesordoc-backlinkonaanddiv;dir=ltr,rtlorauto; anddata-message-author-roleanddata-message-idondiv. An attribute whose value fails its pattern is removed. Everyon*attribute is removed, as aretargetandrelona. - 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=youtubeanddata-idmatching^[A-Za-z0-9_-]{11}$onfigure(the embed placeholder<figure data-embed="youtube" data-id="ID"></figure>, whichapp/utils/embeds.tsexpands into a youtube-nocookie iframe after sanitizing),altwith any text onimg(the editor's free-text alt field; UGC's pattern cuts it at:,?and", and sanitize-html escapes the value), andstartas an integer onol(lists that do not begin at 1). They are the editor's own output, pinned bytests/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, noiframe, no new URL scheme and no new style is allowed, andaltonareakeeps UGC's pattern. - Inline styles: only
color,font-size,font-weightandtext-align, with the values that bluemonday's CSS handlers accept. A named colour must be letters only, sourl()andexpression()never pass. - URLs in
href,srcandcitemust behttp,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:,javascript:andjava<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-samplechecks 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(...).htmlkeeps working and now gets sanitized HTML. An app test that expectsjournal-post-body--blockedor thejournal.post.contentUnavailablestring has to change when the app moves to this version. isSafeJournalHtmlcompares 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.