- displayHtml uses a dedicated DOMPurify 3.4.16 instance: EDITOR_TAGS, href only on <a> through isAllowedLink, DROPPED elements go with their content - string in, string out: parsed into DOMPurify's inert document, nothing adopted into the live document before binding - fails closed (empty string) unless isSupported and a fixed canary hold - Vitest browser mode in Playwright Chromium (npm run test:browser) covers the clobbering vectors, off-origin form, iframe, meta refresh, links and the inert parse; happy-dom keeps engine-independent assertions
151 lines
7.0 KiB
TypeScript
151 lines
7.0 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
|
import type { FormField } from '../../src/api/types'
|
|
import RichEditorField from '../../src/components/form/fields/RichEditorField.vue'
|
|
import { displayHtml, isAllowedLink } from '../../src/components/form/richeditor/html'
|
|
import { EDITOR_TAGS, inertFragment } from '../../src/components/form/richeditor/lossy'
|
|
|
|
// The richeditor read-only display in a real engine (Vitest browser mode,
|
|
// Playwright Chromium; T-10-16, T-17-10). happy-dom reproduces neither form
|
|
// control clobbering nor DOMPurify's walk, so every exact display assertion
|
|
// lives here. Decision note:
|
|
// .planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md
|
|
|
|
enableAutoUnmount(afterEach)
|
|
|
|
/** Problems of output against the display policy; empty when it complies. */
|
|
function policyProblems(output: string): string[] {
|
|
const problems: string[] = []
|
|
for (const element of Array.from(inertFragment(output).querySelectorAll('*'))) {
|
|
const name = element.localName.toLowerCase()
|
|
if (!EDITOR_TAGS.has(name)) {
|
|
problems.push(`element ${name}`)
|
|
}
|
|
for (const attribute of Array.from(element.attributes)) {
|
|
const allowed = name === 'a' && attribute.name === 'href' && isAllowedLink(attribute.value)
|
|
if (!allowed) {
|
|
problems.push(`${name}[${attribute.name}=${attribute.value}]`)
|
|
}
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
/** Asserts output keeps the policy and none of the named elements or on* attributes. */
|
|
function expectClean(output: string, label: string): void {
|
|
expect(policyProblems(output), label).toEqual([])
|
|
const fragment = inertFragment(output)
|
|
for (const tag of ['form', 'input', 'img', 'button', 'iframe', 'meta', 'script']) {
|
|
expect(fragment.querySelector(tag), `${label}: ${tag}`).toBeNull()
|
|
}
|
|
expect(output, label).not.toMatch(/\son[a-z]+\s*=/i)
|
|
}
|
|
|
|
const CLOBBER_VECTORS: Record<string, string> = {
|
|
'form/input name=nodeType + img onerror':
|
|
'<form><input name="nodeType"><img src="/__clobber-a.png" onerror="window.__clobbered = 1"></form>',
|
|
'img name=nodeType': '<img name="nodeType" src="/__clobber-b.png" onerror="window.__clobbered = 2">',
|
|
'button name=nodeType': '<button name="nodeType" onclick="window.__clobbered = 3"><iframe src="/__clobber-c"></iframe>Go</button>',
|
|
'form/button name=nodeType': '<form><button name="nodeType">Go</button><img src="/__clobber-d.png" onerror="window.__clobbered = 4"></form>',
|
|
}
|
|
|
|
describe('richeditor display in a real browser engine', () => {
|
|
it('runs where form controls clobber form properties', () => {
|
|
const doc = new DOMParser().parseFromString('<form><input name="nodeType"></form>', 'text/html')
|
|
const form = doc.querySelector('form')!
|
|
// In a real engine the named control shadows Node.prototype.nodeType.
|
|
expect(typeof (form as unknown as { nodeType: unknown }).nodeType).not.toBe('number')
|
|
})
|
|
|
|
it('drops clobbered forms with everything inside them', () => {
|
|
for (const [label, vector] of Object.entries(CLOBBER_VECTORS)) {
|
|
expectClean(displayHtml(vector), label)
|
|
expectClean(displayHtml(`<div>${vector}</div>`), `${label} nested in a div`)
|
|
}
|
|
expect((window as unknown as { __clobbered?: number }).__clobbered).toBeUndefined()
|
|
})
|
|
|
|
it('keeps a form control named localName from breaking the view', () => {
|
|
let output = ''
|
|
expect(() => {
|
|
output = displayHtml('<form><input name="localName"><p>t</p></form>')
|
|
}).not.toThrow()
|
|
expect(output).toBe('<p>t</p>')
|
|
})
|
|
|
|
it('drops an off-origin form, iframes, meta refresh and disallowed links', () => {
|
|
const output = displayHtml(
|
|
[
|
|
'<form action="https://evil.example/login" method="post"><input type="password" name="password"><button>Go</button></form>',
|
|
'<iframe src="https://evil.example/frame"></iframe>',
|
|
'<meta http-equiv="refresh" content="0;url=https://evil.example/">',
|
|
'<p href="/on-p">para</p>',
|
|
'<a href="javascript:alert(1)">js</a>',
|
|
'<a href="ftp://files.example.test/f">ftp</a>',
|
|
'<a href="sms:+48123456789">sms</a>',
|
|
'<a href="docs/page">rel</a>',
|
|
'<a href="mailto:a@example.test">mail</a>',
|
|
'<a href="/ok">ok</a>',
|
|
].join(''),
|
|
)
|
|
expectClean(output, 'off-origin vectors')
|
|
expect(output).not.toContain('evil.example')
|
|
const fragment = inertFragment(output)
|
|
expect(fragment.querySelector('p')!.hasAttribute('href')).toBe(false)
|
|
const hrefs = Object.fromEntries(Array.from(fragment.querySelectorAll('a')).map((a) => [a.textContent, a.getAttribute('href')]))
|
|
expect(hrefs).toEqual({ js: null, ftp: null, sms: null, rel: 'docs/page', mail: 'mailto:a@example.test', ok: '/ok' })
|
|
})
|
|
|
|
it('parses into an inert document, so nothing loads before the value is bound', async () => {
|
|
const token = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
const probe = `/__display-probe-${token}.png`
|
|
const control = `/__display-control-${token}.png`
|
|
displayHtml(`<p>x<img src="${probe}"></p><form><input name="nodeType"><img src="${probe}?form"></form>`)
|
|
|
|
// The control proves the observation works: a live image does produce an entry.
|
|
const image = document.createElement('img')
|
|
await new Promise<void>((resolve) => {
|
|
image.addEventListener('load', () => resolve(), { once: true })
|
|
image.addEventListener('error', () => resolve(), { once: true })
|
|
image.src = control
|
|
})
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
|
|
const names = performance.getEntriesByType('resource').map((entry) => entry.name)
|
|
expect(names.some((name) => name.includes(control))).toBe(true)
|
|
expect(names.filter((name) => name.includes(probe))).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('richeditor policy helpers', () => {
|
|
it('unwraps unknown elements and drops scripts for display', () => {
|
|
expect(displayHtml('<section><p class="x">a</p></section><script>b</script>')).toBe('<p>a</p>')
|
|
})
|
|
})
|
|
|
|
describe('RichEditorField', () => {
|
|
it('renders read-only values exactly through the display policy', () => {
|
|
const field = { name: 'body', label: 'Body', type: 'richeditor', readOnly: true } as FormField
|
|
const wrapper = mount(RichEditorField, {
|
|
props: {
|
|
field,
|
|
modelValue: '<p onclick="x()">Hi <a href="javascript:alert(1)">bad</a> <a href="/ok">ok</a></p><script>window.__re = 1</script>',
|
|
controlId: 'f-body',
|
|
},
|
|
})
|
|
const box = wrapper.find('[data-richeditor-readonly]')
|
|
expect(box.find('script').exists()).toBe(false)
|
|
expect(box.find('[onclick]').exists()).toBe(false)
|
|
expect(box.findAll('a').map((link) => link.attributes('href'))).toEqual([undefined, '/ok'])
|
|
expect((window as unknown as { __re?: number }).__re).toBeUndefined()
|
|
|
|
const clobbered = mount(RichEditorField, {
|
|
props: { field, modelValue: CLOBBER_VECTORS['form/input name=nodeType + img onerror'], controlId: 'f-body-2' },
|
|
})
|
|
const clobberedBox = clobbered.find('[data-richeditor-readonly]')
|
|
for (const tag of ['form', 'input', 'img']) {
|
|
expect(clobberedBox.find(tag).exists(), tag).toBe(false)
|
|
}
|
|
})
|
|
})
|