Files
vue-journal-layer/tests/htmlGate.test.ts
Jakub Zych 782aa4da7d test: pin embed expansion order and the editor policy mirror
- expansion runs last at the sinks, never in payload transforms
- only the exact placeholder serialization expands; stable across calls
- the Go policy negatives are stripped the same way in the mirror
2026-10-10 16:03:08 +02:00

336 lines
17 KiB
TypeScript

// 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 = [
/<script/i,
/<svg/i,
/<math/i,
/<iframe/i,
/<object/i,
/<embed/i,
/<base/i,
/<meta/i,
/<form/i,
/<input/i,
/<style/i,
/\son[a-z]+\s*=/i,
/javascript/i,
/vbscript/i,
/href="data:/i,
/src="data:/i,
/xlink:href/i,
/evil\.test/i,
]
function assertNeutralized(html: string, label: string): string {
const out = sanitizeJournalHtml(html)
for (const re of DANGER) assert.doesNotMatch(out, re, `${label}: ${out}`)
assert.equal(isSafeJournalHtml(html), false, `${label} reported safe`)
assert.deepEqual(gateJournalHtml(html), { safe: false, html: out }, label)
return out
}
const GO_CASES: Array<[string, string]> = [
['script', '<p>keep</p><script>alert(1)</script>'],
['iframe', '<p>keep</p><iframe src="https://evil.test"></iframe>'],
['event', '<p>keep<img src=x onerror="alert(1)"></p>'],
['javascript', '<a href="javascript:alert(1)">keep</a>'],
['vbscript', '<a href="vbscript:msgbox(1)">keep</a>'],
['data', '<a href="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">keep</a>'],
]
// CR-01: payloads the three-regex gate let through, and their neighbours.
const REVIEW_BYPASSES: Array<[string, string]> = [
['attribute without a space', '<p>keep<img src="x"onerror="alert(1)"></p>'],
['svg with slash separator', '<div>\n<svg/onload=alert(1)>\n</div><p>keep</p>'],
['numeric entity colon', '<a href="javascript&#58;alert(1)">keep</a>'],
['named entity colon', '<a href="javascript&colon;alert(1)">keep</a>'],
['entity in the scheme', '<a href="&#106;avascript:alert(1)">keep</a>'],
['tab in the scheme', '<a href="java\tscript:alert(1)">keep</a>'],
['newline in the scheme', '<a href="java\nscript:alert(1)">keep</a>'],
['leading control characters', '<a href="\u0001 javascript:alert(1)">keep</a>'],
['upper case scheme', '<a href="JAVASCRIPT:alert(1)">keep</a>'],
['MathML xlink:href', '<div>\n<math><maction actiontype=statusline xlink:href="javascript&#58;alert(1)">keep</maction></math>\n</div>'],
['base', '<base href="https://evil.test/"><p>keep</p>'],
['meta refresh', '<meta http-equiv="refresh" content="0;url=https://evil.test"><p>keep</p>'],
['form phish', '<form action="https://evil.test"><input type=password name=p></form><p>keep</p>'],
['embed', '<embed src="https://evil.test/x.swf"><p>keep</p>'],
['object', '<object data="https://evil.test/x"></object><p>keep</p>'],
['data image src', '<p>keep<img src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4="></p>'],
['style element', '<style>body{background:url(https://evil.test/x)}</style><p>keep</p>'],
['css url in style attribute', '<p style="background:url(https://evil.test/x)">keep</p>'],
['css expression in colour', '<p style="color: expression(alert(1))">keep</p>'],
['noscript title breakout', '<noscript><p title="</noscript><img src=x onerror=alert(1)>"></p></noscript><p>keep</p>'],
['handler in upper case', '<P ONMOUSEOVER="alert(1)">keep</P>'],
]
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 = '<!--<img src=x onerror=alert(1)>--><p>keep</p>'
assert.equal(sanitizeJournalHtml(html), '<p>keep</p>')
assert.deepEqual(gateJournalHtml(html), { safe: true, html: '<p>keep</p>' })
})
test('unsafe parts are removed, not the whole value', () => {
assert.equal(sanitizeJournalHtml('<p>Before</p><script>alert(1)</script><p>After</p>'), '<p>Before</p><p>After</p>')
assert.equal(sanitizeJournalHtml('<p><img src="x"onerror="alert(1)"> tekst</p>'), '<p><img src="x" /> tekst</p>')
assert.equal(sanitizeJournalHtml('<a href="javascript&#58;alert(1)">link</a>'), '<a>link</a>')
})
test('text that names a scheme is ordinary text (WR-01)', () => {
const texts = [
'<p>Data: 12 maja 2024</p>',
'<p>Data publikacji: 3 czerwca 2025</p>',
'<p>The metadata: title, author and date.</p>',
'<p>Big data: what it means. JavaScript: a language. vbscript: legacy.</p>',
]
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 <style> block.
const AUTHOR_FOOTER = `<footer class="author-footer" itemscope itemtype="https://schema.org/Person">
<div class="author-info">
<img src="https://blog.acme.test/storage/app/media/Jane%20Doe.png" alt="Zdjęcie autora Jane Doe" class="author-photo" itemprop="image">
<div class="author-details">
<div style="font-size: 1.5em; font-weight: bold;" itemprop="name">Jane Doe</div>
<span style="color: #138c74;" itemprop="jobTitle">Ekspert Mykologii</span>
<p itemprop="description">Jestem entuzjastką grzybów, szczególnie tych funkcjonalnych.</p>
</div>
</div>
</footer>`
test('an imported author footer keeps classes, microdata and inline styles', () => {
const out = sanitizeJournalHtml(AUTHOR_FOOTER)
assert.equal(isSafeJournalHtml(AUTHOR_FOOTER), true)
for (const part of [
'<footer class="author-footer" itemscope="" itemtype="https://schema.org/Person">',
'<div class="author-info">',
'<img src="https://blog.acme.test/storage/app/media/Jane%20Doe.png" alt="Zdjęcie autora Jane Doe" class="author-photo" itemprop="image" />',
'<div style="font-size:1.5em;font-weight:bold" itemprop="name">Jane Doe</div>',
'<span style="color:#138c74" itemprop="jobTitle">Ekspert Mykologii</span>',
'<p itemprop="description">',
]) {
assert.ok(out.includes(part), `missing ${part} in ${out}`)
}
})
test('the footer with its <style> block loses only the style block', () => {
const html = `${AUTHOR_FOOTER}\n<style>\n .author-footer { padding: 20px; }\n</style>\n<hr>`
const out = sanitizeJournalHtml(html)
assert.equal(isSafeJournalHtml(html), false)
assert.doesNotMatch(out, /<style|padding/)
assert.equal(out, `${sanitizeJournalHtml(AUTHOR_FOOTER)}\n\n<hr />`)
})
test('inline styles keep only the four allowed properties with valid values', () => {
assert.equal(
sanitizeJournalHtml('<p style="color: red; font-size: 12px; font-weight: 700; text-align: center; position: fixed">x</p>'),
'<p style="color:red;font-size:12px;font-weight:700;text-align:center">x</p>',
)
assert.equal(sanitizeJournalHtml('<p style="color: rgb(1, 2, 3)">x</p>'), '<p style="color:rgb(1, 2, 3)">x</p>')
assert.equal(sanitizeJournalHtml('<p style="font-size: calc(1px + 1em)">x</p>'), '<p>x</p>')
assert.equal(sanitizeJournalHtml('<p style="position: fixed">x</p>'), '<p>x</p>')
})
test('goldmark markup passes whole: headings, tables, footnotes, links and images', () => {
const safe = [
'<h1>Hello</h1>',
'<table><thead><tr><th>a</th><th style="text-align:right">b</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>',
'<p>See footnote.<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup></p>',
'<div class="footnotes" role="doc-endnotes"><hr /><ol><li id="fn:1"><p>Note. <a href="#fnref:1" class="footnote-backref" role="doc-backlink">↩︎</a></p></li></ol></div>',
'<p><a href="https://blog.acme.test/blog">Artykuły</a> <img src="/storage/app/media/las.webp" alt="las" /></p>',
'<p><a href="mailto:hello@acme.test">mail</a> <a href="//cdn.acme.test/x">cdn</a> <a href="?page=2">page</a></p>',
'<blockquote cite="https://acme.test/source"><p>Quote</p></blockquote>',
'<p class="content">one of the onions</p>',
]
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 = '<div dir="auto" data-message-author-role="user" data-message-id="abc-123">Hi</div>'
assert.equal(sanitizeJournalHtml(html), html)
assert.equal(
sanitizeJournalHtml('<div data-message-author-role="a b" data-other="x" dir="sideways">Hi</div>'),
'<div>Hi</div>',
)
})
test('attribute values must match their patterns', () => {
assert.equal(sanitizeJournalHtml('<p itemtype="https://evil.test/Person">x</p>'), '<p>x</p>')
assert.equal(sanitizeJournalHtml('<p class="a&quot;b">x</p>'), '<p>x</p>')
assert.equal(sanitizeJournalHtml('<a role="button" href="/x">x</a>'), '<a href="/x">x</a>')
assert.equal(sanitizeJournalHtml('<td colspan="2;">x</td>'), '<td>x</td>')
assert.equal(sanitizeJournalHtml('<a href="http://[bad">x</a>'), '<a>x</a>')
assert.equal(sanitizeJournalHtml('<a target="_blank" rel="opener" href="/x">x</a>'), '<a href="/x">x</a>')
})
test('attribute and tag names from Object.prototype find no rule', () => {
assert.equal(
sanitizeJournalHtml('<a constructor="x" tostring="y" __proto__="z" href="/a">x</a><constructor>t</constructor>'),
'<a href="/a">x</a>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 = '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>'
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 &quot;grzyb odporności&quot;?"']) {
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', '<figure data-embed="vimeo" data-id="dQw4w9WgXcQ"></figure>', '<figure data-id="dQw4w9WgXcQ"></figure>'],
['10-character id', '<figure data-embed="youtube" data-id="dQw4w9WgXc"></figure>', '<figure data-embed="youtube"></figure>'],
['12-character id', '<figure data-embed="youtube" data-id="dQw4w9WgXcQQ"></figure>', '<figure data-embed="youtube"></figure>'],
['id with a quote', '<figure data-embed="youtube" data-id="dQw4w9WgX&quot;Q"></figure>', '<figure data-embed="youtube"></figure>'],
['data-id on div', '<div data-embed="youtube" data-id="dQw4w9WgXcQ">x</div>', '<div>x</div>'],
['data-id on img', '<img src="/a.jpg" data-id="dQw4w9WgXcQ" />', '<img src="/a.jpg" />'],
['other data attribute on figure', '<figure data-embed="youtube" data-id="dQw4w9WgXcQ" data-src="https://evil.test/"></figure>', PLACEHOLDER],
['src on figure', '<figure data-embed="youtube" data-id="dQw4w9WgXcQ" src="https://evil.test/"></figure>', PLACEHOLDER],
['onload on figure', '<figure data-embed="youtube" data-id="dQw4w9WgXcQ" onload="alert(1)"></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('<p>a</p><iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ">fallback</iframe><p>b</p>'),
'<p>a</p><p>b</p>',
)
assert.equal(
sanitizeJournalHtml('<figure data-embed="youtube" data-id="dQw4w9WgXcQ"><iframe src="https://evil.test/"></iframe></figure>'),
PLACEHOLDER,
)
})
test('ol start keeps an integer only, and only on ol', () => {
assert.equal(sanitizeJournalHtml('<ol start="3"><li>x</li></ol>'), '<ol start="3"><li>x</li></ol>')
assert.equal(sanitizeJournalHtml('<ol start="x"><li>x</li></ol>'), '<ol><li>x</li></ol>')
assert.equal(sanitizeJournalHtml('<ol start="-1"><li>x</li></ol>'), '<ol><li>x</li></ol>')
assert.equal(sanitizeJournalHtml('<ul start="3"><li>x</li></ul>'), '<ul><li>x</li></ul>')
})
test('img alt keeps free text with a colon, a question mark and quotes', () => {
const html = '<p><img src="/a.jpg" alt="Turkey Tail: czy to &quot;grzyb odporności&quot;?" /></p>'
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('<img src="/a.jpg" alt="&quot;><script>alert(1)</script>" />')
assert.doesNotMatch(out, /<script/)
assert.match(out, /^<img src="\/a\.jpg" alt="[^"]*" \/>$/)
// area alt keeps the UGC pattern; only img alt is widened.
assert.equal(sanitizeJournalHtml('<map name="m"><area alt="a: b?" href="/x" /></map>'), '<map name="m"><area href="/x" /></map>')
})
test('the four editor rules accept exactly the editor values (D-05 amended mirror)', () => {
const kept = [
PLACEHOLDER,
'<p><img src="/a.jpg" alt="any text: with ? and &quot;quotes&quot;" /></p>',
'<ol start="1"><li>a</li></ol>',
'<ol start="42"><li>a</li></ol>',
]
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', '<figure data-embed="YouTube" data-id="dQw4w9WgXcQ"></figure>', '<figure data-id="dQw4w9WgXcQ"></figure>'],
['provider with a suffix', '<figure data-embed="youtube2" data-id="dQw4w9WgXcQ"></figure>', '<figure data-id="dQw4w9WgXcQ"></figure>'],
['empty id', '<figure data-embed="youtube" data-id=""></figure>', '<figure data-embed="youtube"></figure>'],
['id with a URL', '<figure data-embed="youtube" data-id="javascript:1"></figure>', '<figure data-embed="youtube"></figure>'],
['id with a space', '<figure data-embed="youtube" data-id="dQw4w9 gXcQ"></figure>', '<figure data-embed="youtube"></figure>'],
['embed attributes on p', '<p data-embed="youtube" data-id="dQw4w9WgXcQ">x</p>', '<p>x</p>'],
['embed attributes on a', '<a href="/x" data-embed="youtube" data-id="dQw4w9WgXcQ">x</a>', '<a href="/x">x</a>'],
['data attribute alone on figure', '<figure data-url="https://evil.test/"></figure>', '<figure></figure>'],
['script inside the placeholder', '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"><script>alert(1)</script></figure>', PLACEHOLDER],
['iframe alone', '<iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"></iframe>', ''],
['start with a fraction', '<ol start="1.5"><li>a</li></ol>', '<ol><li>a</li></ol>'],
['alt on p', '<p alt="x">y</p>', '<p>y</p>'],
]
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}`)
}
})