diff --git a/README.md b/README.md index b684a0f..afd5ddd 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ 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 gate**: post bodies and category content pass the Journal API's three-pattern gate (raw script, iframe, object or embed tags; event-handler attributes; javascript, vbscript or data URLs) before `v-html`. A value that fails it renders a neutral notice and never reaches the page or the SSR payload. +- **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`. - **i18n**: English and Polish UI strings, registered only for the locales the app enables. ## Usage @@ -56,7 +56,7 @@ export default defineNuxtConfig({ }) ``` -The layer declares `@nuxt/fonts`, `@nuxtjs/i18n` and `@nuxtjs/seo` itself. Do not list them again in the app; if the app must, pin the same exact versions. Static app pages such as `/about` take precedence over a `/:category` route. +The layer declares `@nuxt/fonts`, `@nuxtjs/i18n`, `@nuxtjs/seo` and `sanitize-html` itself. Do not list them again in the app; if the app must, pin the same exact versions. Static app pages such as `/about` take precedence over a `/:category` route. Run the built app with `node .output/server/index.mjs` next to the SummerCMS application and point `NUXT_JOURNAL_API_BASE` at it. @@ -111,7 +111,7 @@ Override tokens in the app's CSS. The app's `css` entries load after the layer's } ``` -Override a component by creating the same file in the app, for example `app/components/Journal/PostCard.vue`. The overridable components are `JournalPostBody`, `JournalPostCard`, `JournalPostList`, `JournalPostMeta`, `JournalFeaturedImage`, `JournalPagination`, `JournalRelatedPosts`, `JournalIndexHeader`, `JournalCategoryHeader`, `JournalAuthorCard` and `JournalRssLink`. A replacement that renders HTML from the API must keep the gate (`gateJournalHtml`) in front of `v-html`. +Override a component by creating the same file in the app, for example `app/components/Journal/PostCard.vue`. The overridable components are `JournalPostBody`, `JournalPostCard`, `JournalPostList`, `JournalPostMeta`, `JournalFeaturedImage`, `JournalPagination`, `JournalRelatedPosts`, `JournalIndexHeader`, `JournalCategoryHeader`, `JournalAuthorCard` and `JournalRssLink`. A replacement that renders HTML from the API must pass it through `sanitizeJournalHtml` (or `gateJournalHtml`, whose `html` is the same sanitized string) before `v-html`. Style classes that imported content uses, such as an author footer, belong in the app's CSS scoped under `.journal-post-body`: the sanitizer removes `

keep

'], + ['css url in style attribute', '

keep

'], + ['css expression in colour', '

keep

'], + ['noscript title breakout', '

keep

'], + ['handler in upper case', '

keep

'], +] + +for (const [name, html] of [...GO_CASES, ...REVIEW_BYPASSES]) { + test(`${name} is neutralized and the rest is kept`, () => { + const out = assertNeutralized(html, name) + assert.match(out, /keep/, `${name} lost the safe text: ${out}`) }) } -test('object and embed tags and spaced handlers are unsafe', () => { - assert.equal(isSafeJournalHtml(''), false) - assert.equal(isSafeJournalHtml(''), false) - assert.equal(isSafeJournalHtml(''), false) +test('the Go cases in upper case are neutralized', () => { + for (const [name, html] of GO_CASES) assertNeutralized(html.toUpperCase(), `${name} upper`) }) -test('headings, tables, footnotes and links are safe and pass unchanged', () => { - const safe = [ - '

Hello

', - '
ab
12
', - '

See footnote.1

', - '

Artykuły las

', +test('comments are dropped; a comment is not content, so the input still counts as safe', () => { + const html = '

keep

' + assert.equal(sanitizeJournalHtml(html), '

keep

') + assert.deepEqual(gateJournalHtml(html), { safe: true, html: '

keep

' }) +}) + +test('unsafe parts are removed, not the whole value', () => { + assert.equal(sanitizeJournalHtml('

Before

After

'), '

Before

After

') + assert.equal(sanitizeJournalHtml('

tekst

'), '

tekst

') + assert.equal(sanitizeJournalHtml('link'), 'link') +}) + +test('text that names a scheme is ordinary text (WR-01)', () => { + const texts = [ + '

Data: 12 maja 2024

', + '

Data publikacji: 3 czerwca 2025

', + '

The metadata: title, author and date.

', + '

Big data: what it means. JavaScript: a language. vbscript: legacy.

', ] - for (const html of safe) { + for (const html of texts) { + assert.equal(sanitizeJournalHtml(html), html) assert.equal(isSafeJournalHtml(html), true, html) assert.deepEqual(gateJournalHtml(html), { safe: true, html }) } }) -test('words starting with "on" are not handlers without an =', () => { - assert.equal(isSafeJournalHtml('

one of the onions

'), true) +// The author footer of an imported post (post 64), without its \n
` + const out = sanitizeJournalHtml(html) + assert.equal(isSafeJournalHtml(html), false) + assert.doesNotMatch(out, /`) +}) + +test('inline styles keep only the four allowed properties with valid values', () => { + assert.equal( + sanitizeJournalHtml('

x

'), + '

x

', + ) + assert.equal(sanitizeJournalHtml('

x

'), '

x

') + assert.equal(sanitizeJournalHtml('

x

'), '

x

') + assert.equal(sanitizeJournalHtml('

x

'), '

x

') +}) + +test('goldmark markup passes whole: headings, tables, footnotes, links and images', () => { + const safe = [ + '

Hello

', + '
ab
12
', + '

See footnote.1

', + '

  1. Note. ↩︎

', + '

Artykuły las

', + '

mail cdn page

', + '

Quote

', + '

one of the onions

', + ] + for (const html of safe) { + assert.equal(sanitizeJournalHtml(html), html) + assert.deepEqual(gateJournalHtml(html), { safe: true, html }) + } +}) + +test('pasted chat transcripts keep dir="auto" and the two data attributes only', () => { + const html = '
Hi
' + assert.equal(sanitizeJournalHtml(html), html) + assert.equal( + sanitizeJournalHtml('
Hi
'), + '
Hi
', + ) +}) + +test('attribute values must match their patterns', () => { + assert.equal(sanitizeJournalHtml('

x

'), '

x

') + assert.equal(sanitizeJournalHtml('

x

'), '

x

') + assert.equal(sanitizeJournalHtml('x'), 'x') + assert.equal(sanitizeJournalHtml('x'), 'x') + assert.equal(sanitizeJournalHtml('x'), 'x') + assert.equal(sanitizeJournalHtml('x'), 'x') +}) + +test('attribute and tag names from Object.prototype find no rule', () => { + assert.equal( + sanitizeJournalHtml('xt'), + 'xt', + ) +}) + +test('sanitizing is idempotent, so server and client agree on a sanitized body', () => { + const inputs = [AUTHOR_FOOTER, ...REVIEW_BYPASSES.map(([, html]) => html), ...GO_CASES.map(([, html]) => html)] + for (const html of inputs) { + const once = sanitizeJournalHtml(html) + assert.equal(sanitizeJournalHtml(once), once, html) + } }) test('empty input is safe and empty', () => { for (const html of [null, undefined, '']) { + assert.equal(sanitizeJournalHtml(html), '') assert.equal(isSafeJournalHtml(html), true) assert.deepEqual(gateJournalHtml(html), { safe: true, html: '' }) }