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
This commit is contained in:
2026-10-12 20:35:57 +02:00
parent fba15c9378
commit c1d767f0f8
9 changed files with 522 additions and 45 deletions

View File

@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } 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 { isAllowedLink } from '../../src/components/form/richeditor/html'
import { isLossy } from '../../src/components/form/richeditor/lossy'
import { companionsFormSchemaFixture, langFixture } from '../fixtures/typed'
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
@@ -160,10 +160,16 @@ describe('RichEditorField', () => {
controlId: 'f-body',
},
})
// happy-dom cannot run DOMPurify faithfully, so displayHtml binds nothing
// here (it fails closed). Exact display assertions live in
// tests/browser/richeditor-display.test.ts; these hold in any engine.
const box = wrapper.find('[data-richeditor-readonly]')
expect(box.find('script').exists()).toBe(false)
expect(box.find('p').attributes('onclick')).toBeUndefined()
expect(box.findAll('a').map((link) => link.attributes('href'))).toEqual([undefined, '/ok'])
expect(box.find('[onclick]').exists()).toBe(false)
expect(box.html()).not.toContain('javascript:')
for (const link of box.findAll('a')) {
expect(isAllowedLink(link.attributes('href'))).toBe(true)
}
expect((window as unknown as { __re?: number }).__re).toBeUndefined()
})
})
@@ -183,10 +189,6 @@ describe('richeditor policy helpers', () => {
expect(isLossy('<p>a</p><H2>b</H2>')).toBe(true)
expect(isLossy('')).toBe(false)
})
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>')
})
})
// Plan 06 coverage: the value contract, the link popover, the notices