// 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 ',
'',
'See footnote.
',
'',
'Artykuły
',
'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('x t '),
'x t',
)
})
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('x '), 'x ')
assert.equal(sanitizeJournalHtml('x '), 'x ')
assert.equal(sanitizeJournalHtml('x '), 'x ')
assert.equal(sanitizeJournalHtml(''), '')
})
test('img alt keeps free text with a colon, a question mark and quotes', () => {
const html = '
'
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(' ')
assert.doesNotMatch(out, /$/)
// area alt keeps the UGC pattern; only img alt is widened.
assert.equal(sanitizeJournalHtml(' '), ' ')
})
test('the four editor rules accept exactly the editor values (D-05 amended mirror)', () => {
const kept = [
PLACEHOLDER,
'
',
'a ',
'a ',
]
for (const html of kept) {
assert.equal(sanitizeJournalHtml(html), html, html)
assert.equal(isSafeJournalHtml(html), true, html)
}
})
test('the Go policy negatives are stripped the same way here', () => {
const cases: Array<[string, string, string]> = [
['upper-case provider', ' ', ' '],
['provider with a suffix', ' ', ' '],
['empty id', ' ', ' '],
['id with a URL', ' ', ' '],
['id with a space', ' ', ' '],
['embed attributes on p', 'x
', 'x
'],
['embed attributes on a', 'x ', 'x '],
['data attribute alone on figure', ' ', ' '],
['script inside the placeholder', ' ', PLACEHOLDER],
['iframe alone', '', ''],
['start with a fraction', 'a ', 'a '],
['alt on p', 'y
', 'y
'],
]
for (const [name, html, want] of cases) {
assert.equal(sanitizeJournalHtml(html), want, name)
assert.equal(isSafeJournalHtml(html), false, `${name} reported safe`)
}
})
test('every corpus entry stays free of danger after the sink pipeline', () => {
for (const entry of CORPUS) {
const out = sanitizeJournalHtml(sanitizeJournalHtml(entry.html))
for (const pattern of DANGER) assert.doesNotMatch(out, pattern, `${entry.name}: ${pattern}`)
}
})