diff --git a/README.md b/README.md index afd5ddd..98ec8fc 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,30 @@ Route paths are configuration, not files. The layer's page components live in `a - **Tag** (`journal-tag`) and **author** (`journal-author`) pages. An author resolves by author slug, or by login once the author has a published post; a login URL sets the canonical to the slug URL. - **RSS**: `/rss.xml` proxies the API feed, and list and post pages link it in the head. - **SEO**: per-page title and description, Open Graph tags (`og:image` from the featured image, article times), canonical URLs that keep `?page=N`, robots and `sitemap.xml` through `@nuxtjs/seo`. -- **HTML sanitizer**: post bodies and category content go through `sanitizeJournalHtml` before `v-html` and before they reach the SSR payload. It is [sanitize-html](https://github.com/apostrophecms/sanitize-html) with the Journal API's own allow-list: the usual content elements, `footer`, `class`, schema.org microdata, the `color`, `font-size`, `font-weight` and `text-align` inline styles, and `http`, `https`, `mailto` or relative URLs. Anything else (`script`, `style`, `iframe`, `svg`, forms, event-handler attributes, `javascript:` URLs in any spelling) is removed and the rest of the value is kept, so a post never renders blank because of one stray tag. Server and browser produce the same string, so hydration matches. See `docs/decisions/0001-sanitize-html.md`. +- **HTML sanitizer**: post bodies and category content go through `sanitizeJournalHtml` before `v-html` and before they reach the SSR payload. It is [sanitize-html](https://github.com/apostrophecms/sanitize-html) with the Journal API's own allow-list: the usual content elements, `footer`, `class`, schema.org microdata, the `color`, `font-size`, `font-weight` and `text-align` inline styles, and `http`, `https`, `mailto` or relative URLs. Four rules keep the Journal Blog Editor's own markup, mirroring the API's policy: `data-embed` (`youtube`) and `data-id` (an 11-character video id) on `figure`, `alt` with any text on `img`, and an integer `start` on `ol`. Anything else (`script`, `style`, `iframe`, `svg`, forms, event-handler attributes, `javascript:` URLs in any spelling) is removed and the rest of the value is kept, so a post never renders blank because of one stray tag. Server and browser produce the same string, so hydration matches. See `docs/decisions/0001-sanitize-html.md`. +- **Embeds**: YouTube videos added in the Journal Blog Editor render as privacy-enhanced iframes (see [Embeds](#embeds)). - **i18n**: English and Polish UI strings, registered only for the locales the app enables. +## Embeds + +The Journal Blog Editor never stores an iframe. It stores a placeholder: + +```html +
+``` + +`expandEmbeds(html)` in `app/utils/embeds.ts` turns it into: + +```html +
+``` + +- **Allowlist:** `EMBED_PROVIDERS` holds one provider, `youtube`, whose id must match `^[A-Za-z0-9_-]{11}$`. The iframe `src` is always `https://www.youtube-nocookie.com/embed/` plus that id. `EMBED_PLACEHOLDER` matches the placeholder exactly as the editor writes it (`data-embed` then `data-id`, double quotes, empty figure). Any other figure, provider or id is left unchanged and renders as an empty figure. +- **Order:** sanitize first, expand last. `JournalPostBody` renders `expandEmbeds(sanitizeJournalHtml(html))` and `JournalCategoryHeader` does the same with the category content. The payload transforms (`toPostView`, the category page) only sanitize, so the payload carries the placeholder. The expansion's output is never sanitized again, because the sanitizer removes every iframe, including this one. A raw `' + + '' + ) + }) +} diff --git a/app/utils/htmlGate.ts b/app/utils/htmlGate.ts index 777c957..a66cce8 100644 --- a/app/utils/htmlGate.ts +++ b/app/utils/htmlGate.ts @@ -5,6 +5,13 @@ // inline styles, goldmark's footnote roles, dir="auto" and two chat // transcript data attributes. URLs must be http, https, mailto or relative. // +// The Blog Editor's own output adds exactly four rules, the same four the Go +// policy gained in Phase 16.2: figure data-embed (youtube only) and data-id +// (an 11-character video id), which carry the embed placeholder that +// embeds.ts expands after sanitizing; img alt with any text (the editor's +// free-text alt field); and ol start (an integer). tests/fixtures/ +// editor-corpus.json, a byte copy of the editor's corpus, pins them. +// // The Journal API already sanitizes what FormatHTML writes. This is defence // in depth for rows written another way (direct SQL, legacy imports) and for // the API itself: anything outside the policy is stripped, never the whole @@ -55,6 +62,13 @@ const CELL_VERTICAL_ALIGN = /^(baseline|bottom|middle|top)$/i const IMAGE_ALIGN = /^(left|right|top|texttop|middle|absmiddle|baseline|bottom|absbottom)$/i const ISO8601 = /^[0-9]{4}(-[0-9]{2}(-[0-9]{2}([ T][0-9]{2}(:[0-9]{2}){1,2}(.[0-9]{1,6})?Z?([+-][0-9]{2}:[0-9]{2})?)?)?)?$/ const FOOTNOTE_ROLE = /^doc-(noteref|endnotes|backlink)$/ +// The Blog Editor's alt field takes free text, which PARAGRAPH cuts at ':', +// '?', '"' and more. Any value is accepted, as bluemonday's p.AllowAttrs("alt") +// does; sanitize-html escapes attribute values on output and alt carries no URL. +const FREE_TEXT = /^[\s\S]*$/ +// The embed placeholder written by the Blog Editor (see embeds.ts). +const EMBED_PROVIDER = /^youtube$/ +const EMBED_ID = /^[A-Za-z0-9_-]{11}$/ // A value pattern; null marks a URL attribute (checked by allowedUrl) or // style (filtered per property by allowedStyles). @@ -105,11 +119,12 @@ const ELEMENT_ATTRS: Record> = { rel: SPACE_SEPARATED_TOKENS, shape: /^(default|circle|rect|poly)$/i, }, - img: { src: null, alt: PARAGRAPH, align: IMAGE_ALIGN, ...SIZE, usemap: /^#[\p{L}\p{N}_-]+$/iu }, + figure: { 'data-embed': EMBED_PROVIDER, 'data-id': EMBED_ID }, + img: { src: null, alt: FREE_TEXT, align: IMAGE_ALIGN, ...SIZE, usemap: /^#[\p{L}\p{N}_-]+$/iu }, time: { datetime: ISO8601 }, del: { cite: PARAGRAPH, datetime: ISO8601 }, ins: { cite: PARAGRAPH, datetime: ISO8601 }, - ol: { type: LIST_TYPE }, + ol: { type: LIST_TYPE, start: INTEGER }, ul: { type: LIST_TYPE }, li: { type: LIST_TYPE, value: INTEGER }, table: { ...SIZE, summary: PARAGRAPH }, diff --git a/docs/decisions/0001-sanitize-html.md b/docs/decisions/0001-sanitize-html.md index 4cb42e1..98a9cee 100644 --- a/docs/decisions/0001-sanitize-html.md +++ b/docs/decisions/0001-sanitize-html.md @@ -16,6 +16,7 @@ The policy mirrors the Journal plugin's bluemonday policy (`classes/format_html. - **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/`) 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 `
`, 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 `javascript:` are all refused. diff --git a/testing/expect-acme.txt b/testing/expect-acme.txt index df716e5..41624d9 100644 --- a/testing/expect-acme.txt +++ b/testing/expect-acme.txt @@ -5,6 +5,7 @@ # Single post 200 /blog/hello-acme contains=Hello Acme +200 /blog/hello-acme contains=https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ 404 /blog/missing-post 200 /blog/unsafe-sample contains=

Before

After

notcontains=alert( 404 /blog/draft-note diff --git a/testing/fixtures/acme/posts.json b/testing/fixtures/acme/posts.json index 85642c8..2dc90e4 100644 --- a/testing/fixtures/acme/posts.json +++ b/testing/fixtures/acme/posts.json @@ -5,7 +5,7 @@ "slug": "hello-acme", "excerpt": "Acme says hello and introduces its blog.", "content": "## Welcome to Acme\n\nAcme builds anvils, rockets and very fast roller skates.\n", - "content_html": "

Welcome to Acme

\n

Acme builds anvils, rockets and very fast roller skates.

\n", + "content_html": "

Welcome to Acme

\n

Acme builds anvils, rockets and very fast roller skates.

\n
\n", "reading_time": 1, "published": true, "published_at": "2026-01-14T09:00:00+00:00", diff --git a/tests/embeds.test.ts b/tests/embeds.test.ts new file mode 100644 index 0000000..819e4d1 --- /dev/null +++ b/tests/embeds.test.ts @@ -0,0 +1,120 @@ +// Embed expansion (Phase 16.2, D-17). The Blog Editor stores the placeholder +//
; the sinks render +// expandEmbeds(sanitizeJournalHtml(html)), so the sanitizer runs first and the +// only iframe in the output is the one built from a validated placeholder. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { EMBED_PLACEHOLDER, EMBED_PROVIDERS, expandEmbeds } from '../app/utils/embeds.ts' +import { sanitizeJournalHtml } from '../app/utils/htmlGate.ts' + +const ID = 'dQw4w9WgXcQ' +const PLACEHOLDER = `
` +const IFRAME = + '
' + + `' + + '
' + +// The sink: sanitize, then expand last. +const sink = (html: string) => expandEmbeds(sanitizeJournalHtml(html)) +// The payload transform sanitizes, and the sink sanitizes again before expanding. +const payloadThenSink = (html: string) => expandEmbeds(sanitizeJournalHtml(sanitizeJournalHtml(html))) + +const iframeCount = (html: string) => (html.match(/' + + `` + + PLACEHOLDER + + '

After

' + for (const run of [sink, payloadThenSink]) { + const out = run(stored) + assert.equal(out, `

Before

${IFRAME}

After

`) + assert.equal(iframeCount(out), 1) + assert.doesNotMatch(out, /evil\.test/) + } +}) + +test('a placeholder smuggling an iframe or extra attributes expands only its validated parts', () => { + const stored = `
` + assert.equal(sink(stored), IFRAME) + assert.equal(payloadThenSink(stored), IFRAME) +}) + +test('a malformed placeholder stays an empty figure after the sink', () => { + assert.equal(sink(`
`), '
') + assert.equal(sink(`
`), `
`) + assert.equal(iframeCount(sink(`
`)), 0) +}) + +test('the payload-then-sink pipeline gives the same result as the sink', () => { + const stored = `

x

${PLACEHOLDER}

y

` + assert.equal(payloadThenSink(stored), sink(stored)) + assert.equal(sink(stored), `

x

${IFRAME}

y

`) +}) + +test('two placeholders in one body both expand', () => { + const other = 'abcdefghijk' + const out = sink(`${PLACEHOLDER}

between

`) + assert.equal(iframeCount(out), 2) + assert.equal(out, `${IFRAME}

between

${IFRAME.replace(ID, other)}`) +}) + +test('the editor corpus embed entries expand after sanitizing', () => { + const corpus = JSON.parse( + readFileSync(new URL('./fixtures/editor-corpus.json', import.meta.url), 'utf8'), + ) as Array<{ name: string; html: string }> + const embed = corpus.find((e) => e.name === 'embed-youtube') + assert.ok(embed) + assert.equal(payloadThenSink(embed.html), IFRAME) + const combined = corpus.find((e) => e.name === 'combined') + assert.ok(combined) + const out = payloadThenSink(combined.html) + assert.ok(out.startsWith(IFRAME), out) + assert.equal(iframeCount(out), 1) + // Entries without a placeholder pass through the expansion unchanged. + for (const entry of corpus.filter((e) => !e.html.includes('data-embed'))) { + const clean = sanitizeJournalHtml(entry.html) + assert.equal(expandEmbeds(clean), clean, entry.name) + } +}) diff --git a/tests/fixtures/editor-corpus.json b/tests/fixtures/editor-corpus.json new file mode 100644 index 0000000..681ce3b --- /dev/null +++ b/tests/fixtures/editor-corpus.json @@ -0,0 +1,62 @@ +[ + { + "name": "embed-youtube", + "html": "
" + }, + { + "name": "image-without-caption", + "html": "

\"Turkey

" + }, + { + "name": "image-with-caption", + "html": "
\"Owocniki
Owocniki wrośniaka różnobarwnego.
" + }, + { + "name": "table-header-row", + "html": "
FormaDawkaUwagi
Proszek1-2 gz posiłkiem
Kapsułki2 x 500 mgrano
" + }, + { + "name": "links", + "html": "

Źródła: strona, badania, redakcja i inny wpis.

" + }, + { + "name": "headings", + "html": "

Nagłówek 2

Nagłówek 3

Nagłówek 4

" + }, + { + "name": "bullet-list", + "html": "
  • Beta-glukany

  • Antyoksydanty

" + }, + { + "name": "ordered-list", + "html": "
  1. Pierwszy krok

  2. Drugi krok

" + }, + { + "name": "blockquote", + "html": "

Grzyby funkcjonalne wspierają organizm.

" + }, + { + "name": "ordered-list-start-3", + "html": "
  1. Trzeci krok

  2. Czwarty krok

" + }, + { + "name": "inline-marks", + "html": "

Tekst pogrubiony, kursywa, przekreślony, podkreślony i kod.

" + }, + { + "name": "code-block", + "html": "
dawka = 2 * 500 // mg
" + }, + { + "name": "horizontal-rule", + "html": "

Przed linią


Po linii

" + }, + { + "name": "hard-break", + "html": "

Pierwsza linia
Druga linia

" + }, + { + "name": "combined", + "html": "

\"Turkey

\"Owocniki
Owocniki wrośniaka różnobarwnego.
FormaDawkaUwagi
Proszek1-2 gz posiłkiem
Kapsułki2 x 500 mgrano

Źródła: strona, badania, redakcja i inny wpis.

Nagłówek 2

Nagłówek 3

Nagłówek 4

  • Beta-glukany

  • Antyoksydanty

  1. Pierwszy krok

  2. Drugi krok

Grzyby funkcjonalne wspierają organizm.

  1. Trzeci krok

  2. Czwarty krok

Tekst pogrubiony, kursywa, przekreślony, podkreślony i kod.

dawka = 2 * 500 // mg

Przed linią


Po linii

Pierwsza linia
Druga linia

" + } +] diff --git a/tests/htmlGate.test.ts b/tests/htmlGate.test.ts index 571f890..843f19d 100644 --- a/tests/htmlGate.test.ts +++ b/tests/htmlGate.test.ts @@ -5,6 +5,7 @@ // dangerous part while the safe text around it stays. import { test } from 'node:test' import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { gateJournalHtml, isSafeJournalHtml, sanitizeJournalHtml } from '../app/utils/htmlGate.ts' // Signs that something executable or navigating survived. @@ -210,3 +211,84 @@ test('empty input is safe and empty', () => { assert.deepEqual(gateJournalHtml(html), { safe: true, html: '' }) } }) + +// The Blog Editor's own output (Phase 16.2, D-05 amended). tests/fixtures/ +// editor-corpus.json is a byte copy of sm-journal-plugin +// tests/blogeditor/fixtures/editor-corpus.json, the editor's corpus that also +// pins the Go policy (TestEditorOutputPassesPolicy). Never edit the copy by +// hand; copy it again when the editor's corpus changes. +const CORPUS = JSON.parse( + readFileSync(new URL('./fixtures/editor-corpus.json', import.meta.url), 'utf8'), +) as Array<{ name: string; html: string }> +const PLACEHOLDER = '
' + +test('the editor corpus has the entries the mirror rules are for', () => { + assert.equal(CORPUS.length, 15) + const all = CORPUS.map((e) => e.html).join('\n') + for (const part of ['data-embed="youtube"', 'start="3"', 'alt="Turkey Tail: czy to "grzyb odporności"?"']) { + assert.ok(all.includes(part), `corpus lacks ${part}`) + } +}) + +for (const entry of CORPUS) { + test(`editor corpus entry ${entry.name} is safe`, () => { + const gate = gateJournalHtml(entry.html) + assert.equal(gate.safe, true, `${entry.name}: ${gate.html}`) + assert.equal(sanitizeJournalHtml(gate.html), gate.html, `${entry.name} not idempotent`) + }) +} + +test('the embed placeholder survives two sanitizer passes (payload, then sink)', () => { + const once = sanitizeJournalHtml(PLACEHOLDER) + assert.equal(once, PLACEHOLDER) + assert.equal(sanitizeJournalHtml(once), PLACEHOLDER) + assert.deepEqual(gateJournalHtml(PLACEHOLDER), { safe: true, html: PLACEHOLDER }) +}) + +test('embed attributes outside the four editor rules are still stripped', () => { + const cases: Array<[string, string, string]> = [ + ['other provider', '
', '
'], + ['10-character id', '
', '
'], + ['12-character id', '
', '
'], + ['id with a quote', '
', '
'], + ['data-id on div', '
x
', '
x
'], + ['data-id on img', '', ''], + ['other data attribute on figure', '
', PLACEHOLDER], + ['src on figure', '
', PLACEHOLDER], + ['onload on figure', '
', PLACEHOLDER], + ] + for (const [name, html, want] of cases) { + assert.equal(sanitizeJournalHtml(html), want, name) + assert.equal(isSafeJournalHtml(html), false, `${name} reported safe`) + } +}) + +test('an iframe is still stripped with its content', () => { + assert.equal( + sanitizeJournalHtml('

a

b

'), + '

a

b

', + ) + assert.equal( + sanitizeJournalHtml('
'), + PLACEHOLDER, + ) +}) + +test('ol start keeps an integer only, and only on ol', () => { + assert.equal(sanitizeJournalHtml('
  1. x
'), '
  1. x
') + assert.equal(sanitizeJournalHtml('
  1. x
'), '
  1. x
') + assert.equal(sanitizeJournalHtml('
  1. x
'), '
  1. x
') + assert.equal(sanitizeJournalHtml('
  • x
'), '
  • x
') +}) + +test('img alt keeps free text with a colon, a question mark and quotes', () => { + const html = '

Turkey Tail: czy to "grzyb odporności"?

' + assert.equal(sanitizeJournalHtml(html), html) + assert.equal(isSafeJournalHtml(html), true) + // alt is text, never markup: an angle bracket stays inside the attribute. + const out = sanitizeJournalHtml('"><script>alert(1)</script>') + assert.doesNotMatch(out, /