diff --git a/package.json b/package.json index 1e6b7d7691a..539a304c75c 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@types/hash-sum": "^1.0.2", "@types/node": "^20.14.13", "@types/semver": "^7.5.8", + "@types/serve-handler": "^6.1.4", "@vitest/coverage-istanbul": "^1.6.0", "@vue/consolidate": "1.0.0", "conventional-changelog-cli": "^5.0.0", @@ -99,6 +100,7 @@ "rollup-plugin-polyfill-node": "^0.13.0", "semver": "^7.6.3", "serve": "^14.2.3", + "serve-handler": "^6.1.5", "simple-git-hooks": "^2.11.1", "todomvc-app-css": "^2.4.3", "tslib": "^2.6.3", diff --git a/packages/runtime-core/src/compat/global.ts b/packages/runtime-core/src/compat/global.ts index f64e7adf210..c21aca58fc5 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.textContent = '' // TODO hydration render(vnode, container, namespace) diff --git a/packages/runtime-dom/package.json b/packages/runtime-dom/package.json index cf09ba6e0f7..5245a12be09 100644 --- a/packages/runtime-dom/package.json +++ b/packages/runtime-dom/package.json @@ -53,5 +53,8 @@ "@vue/runtime-core": "workspace:*", "@vue/reactivity": "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 ab85720faa8..989a5fd3b80 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.textContent = '' 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 ef3ef0748c1..8ed70c7d330 100644 --- a/packages/runtime-dom/src/nodeOps.ts +++ b/packages/runtime-dom/src/nodeOps.ts @@ -1,4 +1,39 @@ +import { warn } from '@vue/runtime-core' import type { RendererOptions } from '@vue/runtime-core' +import type { + TrustedHTML, + TrustedTypePolicy, + TrustedTypesWindow, +} from 'trusted-types/lib' + +let policy: Pick | undefined = + undefined + +const tt = + typeof window !== 'undefined' && + (window as unknown as TrustedTypesWindow).trustedTypes + +if (tt) { + try { + policy = /*#__PURE__*/ tt.createPolicy('vue', { + createHTML: 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. + __DEV__ && warn(`Error creating trusted types policy: ${e}`) + } +} + +// __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. +const unsafeToTrustedHTML: (value: string) => TrustedHTML | string = policy + ? val => policy.createHTML(val) + : val => val export const svgNS = 'http://www.w3.org/2000/svg' export const mathmlNS = 'http://www.w3.org/1998/Math/MathML' @@ -76,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') { 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..927f5949254 --- /dev/null +++ b/packages/vue/__tests__/e2e/trusted-types.spec.ts @@ -0,0 +1,103 @@ +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( + 'should render the 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( + 'should render static vnode without error', + 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( + 'should accept v-html with custom policy', + async () => { + await page().evaluate(() => { + const testPolicy = (window as any).trustedTypes.createPolicy('test', { + createHTML: (input: string): string => input, + }) + + const { createApp, ref, h } = (window as any).Vue + createApp({ + setup() { + const msg = ref('✅success: v-html') + return function render() { + return h('div', { innerHTML: testPolicy.createHTML(msg.value) }) + } + }, + }).mount('#app') + }) + expect(await html('#app')).toContain('
✅success: v-html
') + }, + E2E_TIMEOUT, + ) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b23377766c..f1f5881b22f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@types/semver': specifier: ^7.5.8 version: 7.5.8 + '@types/serve-handler': + specifier: ^6.1.4 + version: 6.1.4 '@vitest/coverage-istanbul': specifier: ^1.6.0 version: 1.6.0(vitest@1.6.0(@types/node@20.14.13)(jsdom@24.1.1)(sass@1.77.8)(terser@5.31.1)) @@ -149,6 +152,9 @@ importers: serve: specifier: ^14.2.3 version: 14.2.3 + serve-handler: + specifier: ^6.1.5 + version: 6.1.5 simple-git-hooks: specifier: ^2.11.1 version: 2.11.1 @@ -325,6 +331,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: @@ -1220,6 +1230,12 @@ packages: '@types/semver@7.5.8': resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} + '@types/serve-handler@6.1.4': + resolution: {integrity: sha512-aXy58tNie0NkuSCY291xUxl0X+kGYy986l4kqW6Gi4kEXgr6Tx0fpSH7YwUSa5usPpG3s9DBeIR6hHcDtL2IvQ==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -4204,6 +4220,12 @@ snapshots: '@types/semver@7.5.8': {} + '@types/serve-handler@6.1.4': + dependencies: + '@types/node': 20.14.13 + + '@types/trusted-types@2.0.7': {} + '@types/yauzl@2.10.3': dependencies: '@types/node': 20.14.13