diff --git a/CLAUDE.md b/CLAUDE.md index fc2ba23..224f5a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index fd605d9..6b5acf7 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/admin/package-lock.json b/admin/package-lock.json index bc90598..294d90a 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -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", diff --git a/admin/package.json b/admin/package.json index c7cc45a..f6b0bec 100644 --- a/admin/package.json +++ b/admin/package.json @@ -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", diff --git a/admin/src/components/form/richeditor/html.ts b/admin/src/components/form/richeditor/html.ts index 4bbab94..796799d 100644 --- a/admin/src/components/form/richeditor/html.ts +++ b/admin/src/components/form/richeditor/html.ts @@ -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 = 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 = '

ad

' +const CANARY_CLEAN = '

ad

' + +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) } diff --git a/admin/tests/browser/richeditor-display.test.ts b/admin/tests/browser/richeditor-display.test.ts new file mode 100644 index 0000000..bf3d3a1 --- /dev/null +++ b/admin/tests/browser/richeditor-display.test.ts @@ -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 = { + 'form/input name=nodeType + img onerror': + '
', + 'img name=nodeType': '', + 'button name=nodeType': '', + 'form/button name=nodeType': '
', +} + +describe('richeditor display in a real browser engine', () => { + it('runs where form controls clobber form properties', () => { + const doc = new DOMParser().parseFromString('
', '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(`
${vector}
`), `${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('

t

') + }).not.toThrow() + expect(output).toBe('

t

') + }) + + it('drops an off-origin form, iframes, meta refresh and disallowed links', () => { + const output = displayHtml( + [ + '
', + '', + '', + '

para

', + 'js', + 'ftp', + 'sms', + 'rel', + 'mail', + 'ok', + ].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(`

x

`) + + // The control proves the observation works: a live image does produce an entry. + const image = document.createElement('img') + await new Promise((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('

a

')).toBe('

a

') + }) +}) + +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: '

Hi bad ok

', + 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) + } + }) +}) diff --git a/admin/tests/form/RichEditorField.test.ts b/admin/tests/form/RichEditorField.test.ts index 53749ec..7047129 100644 --- a/admin/tests/form/RichEditorField.test.ts +++ b/admin/tests/form/RichEditorField.test.ts @@ -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('

a

b

')).toBe(true) expect(isLossy('')).toBe(false) }) - - it('unwraps unknown elements and drops scripts for display', () => { - expect(displayHtml('

a

')).toBe('

a

') - }) }) // Plan 06 coverage: the value contract, the link popover, the notices diff --git a/admin/vitest.browser.config.ts b/admin/vitest.browser.config.ts new file mode 100644 index 0000000..f794c51 --- /dev/null +++ b/admin/vitest.browser.config.ts @@ -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' }], + }, + }, +}) diff --git a/admin/vitest.config.ts b/admin/vitest.config.ts index 8a2d0a2..123a324 100644 --- a/admin/vitest.config.ts +++ b/admin/vitest.config.ts @@ -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, },