- 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
336 lines
17 KiB
TypeScript
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:alert(1)">keep</a>'],
|
|
['named entity colon', '<a href="javascript:alert(1)">keep</a>'],
|
|
['entity in the scheme', '<a href="javascript: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: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: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"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 "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', '<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"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 "grzyb odporności"?" /></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=""><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 "quotes"" /></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}`)
|
|
}
|
|
})
|