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

@@ -25,7 +25,7 @@ summer docs:build --check # docs checker (identifiers, links,
summer docs:sync # refresh src= code copies in docs/ after changing their source
npm --prefix admin ci
npm --prefix admin run test | typecheck | build | dev
npm --prefix admin run test | test:browser | typecheck | build | dev
scripts/check-admin-openapi.sh [--check] # swag annotations in cabana -> admin/openapi/admin.json -> admin/src/api/schema.d.ts
scripts/check-admin-dist.sh # committed modules/boardwalk/dist must match a fresh admin build
scripts/check-phaseN.sh # per-phase verification gates

View File

@@ -154,6 +154,7 @@ npm --prefix admin run dev # Vite dev server
npm --prefix admin run build # type-check, then build into modules/boardwalk/dist
npm --prefix admin run typecheck # vue-tsc only
npm --prefix admin run test # Vitest
npm --prefix admin run test:browser # Vitest browser mode in Playwright Chromium (needs the chromium build; npx --prefix admin playwright install chromium once)
npm --prefix admin run gen:api # regenerate TypeScript types from admin/openapi/admin.json
```

277
admin/package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@tiptap/core": "3.31.4",
"@tiptap/pm": "3.31.4",
"@tiptap/starter-kit": "3.31.4",
"dompurify": "3.4.16",
"openapi-fetch": "0.17.0",
"reka-ui": "2.9.10",
"vue": "3.5.35",
@@ -21,9 +22,11 @@
"devDependencies": {
"@tailwindcss/vite": "4.3.0",
"@vitejs/plugin-vue": "6.0.8",
"@vitest/browser": "3.2.7",
"@vue/test-utils": "2.4.11",
"happy-dom": "20.11.6",
"openapi-typescript": "7.13.0",
"playwright": "1.60.0",
"tailwindcss": "4.3.0",
"typescript": "5.9.3",
"vite": "7.3.5",
@@ -145,6 +148,16 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
@@ -791,6 +804,13 @@
"node": ">=14"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"license": "MIT"
},
"node_modules/@redocly/ajv": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
@@ -1540,6 +1560,40 @@
"vue": "^2.7.0 || ^3.0.0"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.2",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz",
"integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/user-event": {
"version": "14.6.7",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz",
"integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12",
"npm": ">=6"
},
"peerDependencies": {
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@tiptap/core": {
"version": "3.31.4",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.4.tgz",
@@ -1909,6 +1963,13 @@
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -1950,6 +2011,13 @@
"undici-types": "~8.9.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
@@ -1990,6 +2058,42 @@
"vue": "^3.2.25"
}
},
"node_modules/@vitest/browser": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.7.tgz",
"integrity": "sha512-gIzazUkbQfv6T1rJHOLhMMKQnplKAvvQ7QNGaFwI6oCsp4z2aSDZCojGpX3QX3+MYsvJdyy/8BRIYVEbAkMkEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/user-event": "^14.6.1",
"@vitest/mocker": "3.2.7",
"@vitest/utils": "3.2.7",
"magic-string": "^0.30.17",
"sirv": "^3.0.1",
"tinyrainbow": "^2.0.0",
"ws": "^8.18.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"playwright": "*",
"vitest": "3.2.7",
"webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0"
},
"peerDependenciesMeta": {
"playwright": {
"optional": true
},
"safaridriver": {
"optional": true
},
"webdriverio": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
@@ -2467,6 +2571,16 @@
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -2717,6 +2831,16 @@
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"license": "MIT"
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2727,6 +2851,22 @@
"node": ">=8"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.4.16",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
"integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -3442,6 +3582,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -3521,6 +3671,16 @@
"pathe": "^2.0.1"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3726,6 +3886,53 @@
"pathe": "^2.0.3"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
@@ -3764,6 +3971,44 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pretty-format/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz",
@@ -3926,6 +4171,13 @@
],
"license": "MIT"
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"node_modules/readdirp": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
@@ -4088,6 +4340,21 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -4329,6 +4596,16 @@
"node": ">=14.0.0"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",

View File

@@ -10,6 +10,7 @@
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
"test:browser": "vitest run --config vitest.browser.config.ts",
"gen:api": "openapi-typescript openapi/admin.json -o src/api/schema.d.ts"
},
"dependencies": {
@@ -20,6 +21,7 @@
"@tiptap/core": "3.31.4",
"@tiptap/pm": "3.31.4",
"@tiptap/starter-kit": "3.31.4",
"dompurify": "3.4.16",
"openapi-fetch": "0.17.0",
"reka-ui": "2.9.10",
"vue": "3.5.35",
@@ -28,9 +30,11 @@
"devDependencies": {
"@tailwindcss/vite": "4.3.0",
"@vitejs/plugin-vue": "6.0.8",
"@vitest/browser": "3.2.7",
"@vue/test-utils": "2.4.11",
"happy-dom": "20.11.6",
"openapi-typescript": "7.13.0",
"playwright": "1.60.0",
"tailwindcss": "4.3.0",
"typescript": "5.9.3",
"vite": "7.3.5",

View File

@@ -2,7 +2,13 @@
// The server sanitizer (seaglass) is the gate on every save; these client
// checks keep the editor from offering what the gate would remove and keep a
// stored value that predates the gate from running in the admin.
import { EDITOR_TAGS, inertFragment } from './lossy'
//
// The read-only display is sanitized by DOMPurify (T-10-16, T-17-10), not by
// a hand-built walker: form controls named like DOM properties (nodeType,
// localName) clobber what a walker reads and let it skip a subtree. Decision
// note: .planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md
import DOMPurify, { type Config, type DOMPurify as Purifier } from 'dompurify'
import { EDITOR_TAGS } from './lossy'
/** Link schemes the editor applies; anything else must be a relative URL. */
export const LINK_SCHEMES: readonly string[] = ['http', 'https', 'mailto', 'tel']
@@ -37,7 +43,7 @@ export function isAllowedLink(url: string | null | undefined): boolean {
}
/** Elements dropped with their content when a value is displayed. */
const DROPPED = new Set([
const DROPPED: readonly string[] = [
'script',
'style',
'template',
@@ -55,50 +61,64 @@ const DROPPED = new Set([
'meta',
'link',
'base',
])
]
function clean(parent: ParentNode): void {
for (const node of Array.from(parent.childNodes)) {
if (node.nodeType === Node.COMMENT_NODE) {
node.remove()
continue
}
if (node.nodeType !== Node.ELEMENT_NODE) {
continue
}
const element = node as Element
const name = element.localName.toLowerCase()
if (DROPPED.has(name)) {
element.remove()
continue
}
clean(element)
if (!EDITOR_TAGS.has(name)) {
// Another element keeps its text: its children take its place.
element.replaceWith(...Array.from(element.childNodes))
continue
}
for (const attribute of Array.from(element.attributes)) {
const keep = name === 'a' && attribute.name === 'href' && isAllowedLink(attribute.value)
if (!keep) {
element.removeAttribute(attribute.name)
// Only the editor's elements and a link's href. DOMPurify parses the string
// with DOMParser into an inert document and returns a string: no node is
// adopted into the live document and nothing loads while sanitizing. Never
// RETURN_DOM, RETURN_DOM_FRAGMENT or IN_PLACE, and never a Node as input.
const CONFIG: Readonly<Config> = Object.freeze({
ALLOWED_TAGS: [...EDITOR_TAGS],
ALLOWED_ATTR: ['href'],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
ADD_FORBID_CONTENTS: [...DROPPED],
KEEP_CONTENT: true,
RETURN_TRUSTED_TYPE: false,
})
// What the canary must sanitize to. DOMPurify returns its input unchanged
// where it cannot run, and under happy-dom it visits only top-level nodes, so
// displayHtml binds nothing unless this exact answer comes back.
const CANARY = '<p onclick="x"><b>a</b><script>b</script><a href="javascript:c">d</a></p>'
const CANARY_CLEAN = '<p><b>a</b><a>d</a></p>'
let purifier: Purifier | null = null
let usable: boolean | null = null
/** The dedicated DOMPurify instance; its hook is not registered globally. */
function instance(): Purifier | null {
if (purifier === null && typeof window !== 'undefined') {
purifier = DOMPurify(window)
purifier.addHook('uponSanitizeAttribute', (node, data) => {
// Clobbered forms are removed before attributes are visited, and only
// forms expose named controls, so nodeName is the element's own.
if (data.attrName === 'href' && (node.nodeName !== 'A' || !isAllowedLink(data.attrValue))) {
data.keepAttr = false
}
}
})
}
return purifier
}
/** Whether DOMPurify runs faithfully here (isSupported and the canary). */
function sanitizerUsable(): boolean {
if (usable === null) {
const current = instance()
usable = current !== null && current.isSupported && current.sanitize(CANARY, CONFIG) === CANARY_CLEAN
}
return usable
}
/**
* The read-only rendering of a stored value: only the editor's elements,
* only allowed link hrefs, no other attribute, no comment. It is what may be
* bound as HTML in the admin; the stored value itself is never bound.
* bound as HTML in the admin; the stored value itself is never bound. Fails
* closed: where the sanitizer cannot run faithfully it returns ''.
*/
export function displayHtml(html: string): string {
if (html === '') {
if (html === '' || !sanitizerUsable()) {
return ''
}
const fragment = inertFragment(html)
clean(fragment)
const holder = document.createElement('div')
holder.append(fragment)
return holder.innerHTML
return instance()!.sanitize(html, CONFIG)
}

View File

@@ -0,0 +1,150 @@
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)
}
})
})

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

View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
// Vitest browser mode in Playwright Chromium (npm run test:browser), for tests
// that need a real engine: happy-dom reproduces neither DOM clobbering nor
// DOMPurify's walk. tests/setup.ts is a happy-dom shim and is not loaded here.
// Decision note: .planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md
export default defineConfig({
plugins: [vue()],
test: {
include: ['tests/browser/**/*.test.ts'],
restoreMocks: true,
browser: {
enabled: true,
provider: 'playwright',
headless: true,
screenshotFailures: false,
instances: [{ browser: 'chromium' }],
},
},
})

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'vitest/config'
import { configDefaults, defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
@@ -6,6 +6,8 @@ export default defineConfig({
test: {
environment: 'happy-dom',
include: ['tests/**/*.test.ts'],
// tests/browser needs a real engine: vitest.browser.config.ts.
exclude: [...configDefaults.exclude, 'tests/browser/**'],
setupFiles: ['tests/setup.ts'],
restoreMocks: true,
},