- 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
55 lines
8.7 KiB
Markdown
55 lines
8.7 KiB
Markdown
# 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:`, `javascript:` 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.
|