// 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 { 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: '' })
}
})