// The Journal HTML sanitizer mirrors the Journal plugin's bluemonday policy // (classes/format_html.go). The unsafe cases are the six of // TestFormatHTMLRejectsUnsafeHTML plus every bypass from the Phase 16.1 code // review (CR-01), as rendered HTML. Each one must come out without the // 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. const DANGER = [ /'], ['iframe', '

keep

'], ['event', '

keep

'], ['javascript', 'keep'], ['vbscript', 'keep'], ['data', 'keep'], ] // CR-01: payloads the three-regex gate let through, and their neighbours. const REVIEW_BYPASSES: Array<[string, string]> = [ ['attribute without a space', '

keep

'], ['svg with slash separator', '
\n\n

keep

'], ['numeric entity colon', 'keep'], ['named entity colon', 'keep'], ['entity in the scheme', 'keep'], ['tab in the scheme', 'keep'], ['newline in the scheme', 'keep'], ['leading control characters', 'keep'], ['upper case scheme', 'keep'], ['MathML xlink:href', '
\nkeep\n
'], ['base', '

keep

'], ['meta refresh', '

keep

'], ['form phish', '

keep

'], ['embed', '

keep

'], ['object', '

keep

'], ['data image src', '

keep

'], ['style element', '

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('the Go cases in upper case are neutralized', () => { for (const [name, html] of GO_CASES) assertNeutralized(html.toUpperCase(), `${name} upper`) }) 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 texts) { assert.equal(sanitizeJournalHtml(html), html) assert.equal(isSafeJournalHtml(html), true, html) assert.deepEqual(gateJournalHtml(html), { safe: true, html }) } }) // 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: '' }) } }) // 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(''), '') }) 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, /