Files
summercms/admin/tests/browser/richeditor-display.test.ts
Jakub Zych c1d767f0f8 fix(admin): sanitize the richeditor read-only display with DOMPurify (T-10-16)
- 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
2026-10-12 20:35:57 +02:00

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)
}
})
})