From 286be0457c65b769664f83b770a616bfa484bf97 Mon Sep 17 00:00:00 2001 From: Haoqun Jiang Date: Tue, 30 Apr 2024 16:43:12 +0800 Subject: [PATCH 01/15] fix: ensure no trusted types violations on fresh mount This isn't a complete fix because `innerHTML` assignment also occurs in `insertStaticContent`. But at least this makes the hello-world app work with trusted types enabled. I'm still figuring out how to add test cases for trusted types. Remaining todos: - [ ] Add test cases for trusted types. - [ ] Fix `insertStaticContent` to be compatible with trusted types, we may need a `vue` policy for that case. - [ ] Add a note in the docs about trusted types compatibility. - [ ] Allow trusted values to be passed to `v-html` and other props, this ultimately fixes https://github.com/vuejs/rfcs/discussions/614 --- packages/runtime-core/src/compat/global.ts | 2 +- packages/runtime-dom/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime-core/src/compat/global.ts b/packages/runtime-core/src/compat/global.ts index 22f7b521396..1ff127f633b 100644 --- a/packages/runtime-core/src/compat/global.ts +++ b/packages/runtime-core/src/compat/global.ts @@ -548,7 +548,7 @@ function installCompatMount( } // clear content before mounting - container.innerHTML = '' + container.replaceChildren() // TODO hydration render(vnode, container, namespace) diff --git a/packages/runtime-dom/src/index.ts b/packages/runtime-dom/src/index.ts index ab85720faa8..a4257082929 100644 --- a/packages/runtime-dom/src/index.ts +++ b/packages/runtime-dom/src/index.ts @@ -123,7 +123,7 @@ export const createApp = ((...args) => { } // clear content before mounting - container.innerHTML = '' + container.replaceChildren() const proxy = mount(container, false, resolveRootNamespace(container)) if (container instanceof Element) { container.removeAttribute('v-cloak') From 455861f637aadaa1196063baa5a09fbd2778f50f Mon Sep 17 00:00:00 2001 From: Haoqun Jiang Date: Sun, 5 May 2024 00:21:05 +0800 Subject: [PATCH 02/15] fix(runtime-dom): use a trusted policy for innerHTML assignment in app mounting Because `replaceChildren` isn't supported in all browsers, we still need to use `innerHTML` to clear the container before mounting the app. I left the compat mode implementation in `runtime-core` as is because it doesn't feel right to use a DOM-only API in `runtime-core`. --- packages/runtime-core/src/compat/global.ts | 2 +- packages/runtime-dom/package.json | 5 ++- packages/runtime-dom/src/index.ts | 4 +-- packages/runtime-dom/src/nodeOps.ts | 37 ++++++++++++++++++++++ pnpm-lock.yaml | 9 ++++++ 5 files changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/runtime-core/src/compat/global.ts b/packages/runtime-core/src/compat/global.ts index 1ff127f633b..22f7b521396 100644 --- a/packages/runtime-core/src/compat/global.ts +++ b/packages/runtime-core/src/compat/global.ts @@ -548,7 +548,7 @@ function installCompatMount( } // clear content before mounting - container.replaceChildren() + container.innerHTML = '' // TODO hydration render(vnode, container, namespace) diff --git a/packages/runtime-dom/package.json b/packages/runtime-dom/package.json index 28c2f60f81b..58344486b86 100644 --- a/packages/runtime-dom/package.json +++ b/packages/runtime-dom/package.json @@ -49,8 +49,11 @@ }, "homepage": "https://github.com/vuejs/core/tree/main/packages/runtime-dom#readme", "dependencies": { - "@vue/shared": "workspace:*", "@vue/runtime-core": "workspace:*", + "@vue/shared": "workspace:*", "csstype": "^3.1.3" + }, + "devDependencies": { + "@types/trusted-types": "^2.0.7" } } diff --git a/packages/runtime-dom/src/index.ts b/packages/runtime-dom/src/index.ts index a4257082929..01648aab1bc 100644 --- a/packages/runtime-dom/src/index.ts +++ b/packages/runtime-dom/src/index.ts @@ -15,7 +15,7 @@ import { isRuntimeOnly, warn, } from '@vue/runtime-core' -import { nodeOps } from './nodeOps' +import { nodeOps, unsafeToTrustedHTML } from './nodeOps' import { patchProp } from './patchProp' // Importing from the compiler, will be tree-shaken in prod import { @@ -123,7 +123,7 @@ export const createApp = ((...args) => { } // clear content before mounting - container.replaceChildren() + container.innerHTML = unsafeToTrustedHTML('') as string const proxy = mount(container, false, resolveRootNamespace(container)) if (container instanceof Element) { container.removeAttribute('v-cloak') diff --git a/packages/runtime-dom/src/nodeOps.ts b/packages/runtime-dom/src/nodeOps.ts index a318000aa91..4aab75364b5 100644 --- a/packages/runtime-dom/src/nodeOps.ts +++ b/packages/runtime-dom/src/nodeOps.ts @@ -1,4 +1,41 @@ +import { warn } from '@vue/runtime-core' import type { RendererOptions } from '@vue/runtime-core' +import type { + TrustedHTML, + TrustedTypePolicy, + TrustedTypesWindow, +} from 'trusted-types/lib' + +let policy: TrustedTypePolicy | undefined = undefined +function getPolicy(): TrustedTypePolicy | undefined { + const ttWindow = window as unknown as TrustedTypesWindow + if (ttWindow.trustedTypes && !policy) { + try { + policy = ttWindow.trustedTypes.createPolicy('vue', { + createHTML: val => val, + createScript: val => val, + createScriptURL: val => val, + }) + } catch (e: unknown) { + // `createPolicy` throws a TypeError if the name is a duplicate + // and the CSP trusted-types directive is not using `allow-duplicates`. + // So we have to catch that error. + warn(`Error creating trusted types policy: ${e}`) + } + } + return policy +} + +// __UNSAFE__ +// Reason: potentially setting innerHTML. +// This function merely perform a type-level trusted type conversion +// for use in `innerHTML` assignment, etc. +// Be careful of whatever value passed to this function. +function unsafeToTrustedHTML(value: string): TrustedHTML | string { + return getPolicy()?.createHTML(value) || value +} + +export { getPolicy, unsafeToTrustedHTML } export const svgNS = 'http://www.w3.org/2000/svg' export const mathmlNS = 'http://www.w3.org/1998/Math/MathML' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db9ebbdc939..976100dcdf2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -316,6 +316,10 @@ importers: csstype: specifier: ^3.1.3 version: 3.1.3 + devDependencies: + '@types/trusted-types': + specifier: ^2.0.7 + version: 2.0.7 packages/runtime-test: dependencies: @@ -1149,6 +1153,9 @@ packages: '@types/semver@7.5.8': resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -4179,6 +4186,8 @@ snapshots: '@types/semver@7.5.8': {} + '@types/trusted-types@2.0.7': {} + '@types/yauzl@2.10.3': dependencies: '@types/node': 20.12.12 From 3ef9b8c7b27fc807b130e4fca86bf4773170e62f Mon Sep 17 00:00:00 2001 From: Haoqun Jiang Date: Sun, 5 May 2024 00:45:16 +0800 Subject: [PATCH 03/15] fix: trusted types compat for `insertStaticContent` It seems that the only source of the `content` is the output of `stringifyStatic` in `compiler-dom`. I guess no additional check is needed here. --- packages/runtime-dom/src/nodeOps.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/runtime-dom/src/nodeOps.ts b/packages/runtime-dom/src/nodeOps.ts index 4aab75364b5..dea6ed6dc28 100644 --- a/packages/runtime-dom/src/nodeOps.ts +++ b/packages/runtime-dom/src/nodeOps.ts @@ -111,12 +111,13 @@ export const nodeOps: Omit, 'patchProp'> = { } } else { // fresh insert - templateContainer.innerHTML = + templateContainer.innerHTML = unsafeToTrustedHTML( namespace === 'svg' ? `${content}` : namespace === 'mathml' ? `${content}` - : content + : content, + ) as string const template = templateContainer.content if (namespace === 'svg' || namespace === 'mathml') { From f85347946a325b784cb6cd4067801ca9448d0c48 Mon Sep 17 00:00:00 2001 From: Haoqun Jiang Date: Mon, 6 May 2024 15:24:31 +0800 Subject: [PATCH 04/15] test: add e2e tests for trusted types compatibility --- package.json | 2 + packages/vue/__tests__/e2e/trusted-types.html | 17 ++++ .../vue/__tests__/e2e/trusted-types.spec.ts | 87 +++++++++++++++++++ pnpm-lock.yaml | 13 +++ 4 files changed, 119 insertions(+) create mode 100644 packages/vue/__tests__/e2e/trusted-types.html create mode 100644 packages/vue/__tests__/e2e/trusted-types.spec.ts diff --git a/package.json b/package.json index d454391c703..2ec9e8a9e39 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@types/minimist": "^1.2.5", "@types/node": "^20.12.12", "@types/semver": "^7.5.8", + "@types/serve-handler": "^6.1.4", "@vitest/coverage-istanbul": "^1.5.2", "@vue/consolidate": "1.0.0", "conventional-changelog-cli": "^4.1.0", @@ -103,6 +104,7 @@ "rollup-plugin-polyfill-node": "^0.13.0", "semver": "^7.6.2", "serve": "^14.2.3", + "serve-handler": "^6.1.5", "simple-git-hooks": "^2.11.1", "terser": "^5.31.0", "todomvc-app-css": "^2.4.3", diff --git a/packages/vue/__tests__/e2e/trusted-types.html b/packages/vue/__tests__/e2e/trusted-types.html new file mode 100644 index 00000000000..a5743a63090 --- /dev/null +++ b/packages/vue/__tests__/e2e/trusted-types.html @@ -0,0 +1,17 @@ + + + + + + + Vue App + + + + +
+ + diff --git a/packages/vue/__tests__/e2e/trusted-types.spec.ts b/packages/vue/__tests__/e2e/trusted-types.spec.ts new file mode 100644 index 00000000000..3afa470c491 --- /dev/null +++ b/packages/vue/__tests__/e2e/trusted-types.spec.ts @@ -0,0 +1,87 @@ +import { once } from 'node:events' +import { createServer } from 'node:http' +import path from 'node:path' +import { beforeAll } from 'vitest' +import serveHandler from 'serve-handler' + +import { E2E_TIMEOUT, setupPuppeteer } from './e2eUtils' + +// use the `vue` package root as the public directory +// because we need to serve the Vue runtime for the tests +const serverRoot = path.resolve(import.meta.dirname, '../../') +const testPort = 9090 +const basePath = path.relative( + serverRoot, + path.resolve(import.meta.dirname, './trusted-types.html'), +) +const baseUrl = `http://localhost:${testPort}/${basePath}` + +const { page, html } = setupPuppeteer() + +let server: ReturnType +beforeAll(async () => { + // sets up the static server + server = createServer((req, res) => { + return serveHandler(req, res, { + public: serverRoot, + cleanUrls: false, + }) + }) + + server.listen(testPort) + await once(server, 'listening') +}) + +afterAll(async () => { + server.close() + await once(server, 'close') +}) + +describe('e2e: trusted types', () => { + beforeEach(async () => { + await page().goto(baseUrl) + await page().waitForSelector('#app') + }) + + test( + 'hello world app', + async () => { + await page().evaluate(() => { + const { createApp, ref, h } = (window as any).Vue + createApp({ + setup() { + const msg = ref('✅success: hello world') + return function render() { + return h('div', msg.value) + } + }, + }).mount('#app') + }) + expect(await html('#app')).toContain('
✅success: hello world
') + }, + E2E_TIMEOUT, + ) + + test( + 'static vnode', + async () => { + await page().evaluate(() => { + const { createApp, createStaticVNode } = (window as any).Vue + createApp({ + render() { + return createStaticVNode('
✅success: static vnode
') + }, + }).mount('#app') + }) + expect(await html('#app')).toContain('
✅success: static vnode
') + }, + E2E_TIMEOUT, + ) + + test.todo('v-html with custom policy') + test.todo( + 'other sensitive props (