diff --git a/src/core/config.js b/src/core/config.js index cae9d3dd395..039dbb5c8ae 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -33,6 +33,9 @@ export type Config = { // legacy _lifecycleHooks: Array; + + // trusted types (https://github.com/WICG/trusted-types) + trustedTypesPolicyName: string; }; export default ({ @@ -126,5 +129,11 @@ export default ({ /** * Exposed for legacy reasons */ - _lifecycleHooks: LIFECYCLE_HOOKS + _lifecycleHooks: LIFECYCLE_HOOKS, + + /** + * Trusted Types policy name which will be used by Vue. More + * info about Trusted Types on https://github.com/WICG/trusted-types. + */ + trustedTypesPolicyName: 'vue' }: Config) diff --git a/src/platforms/web/runtime/modules/dom-props.js b/src/platforms/web/runtime/modules/dom-props.js index a5c3dc7f4aa..00cf182674f 100644 --- a/src/platforms/web/runtime/modules/dom-props.js +++ b/src/platforms/web/runtime/modules/dom-props.js @@ -2,6 +2,7 @@ import { isDef, isUndef, extend, toNumber } from 'shared/util' import { isSVG } from 'web/util/index' +import {maybeCreateDangerousSvgHTML} from 'web/security' let svgContainer @@ -20,6 +21,7 @@ function updateDOMProps (oldVnode: VNodeWithData, vnode: VNodeWithData) { for (key in oldProps) { if (!(key in props)) { + // TT_TODO: when (how) is this even called elm[key] = '' } } @@ -51,7 +53,7 @@ function updateDOMProps (oldVnode: VNodeWithData, vnode: VNodeWithData) { } else if (key === 'innerHTML' && isSVG(elm.tagName) && isUndef(elm.innerHTML)) { // IE doesn't support innerHTML for SVG elements svgContainer = svgContainer || document.createElement('div') - svgContainer.innerHTML = `${cur}` + svgContainer.innerHTML = maybeCreateDangerousSvgHTML(cur) const svg = svgContainer.firstChild while (elm.firstChild) { elm.removeChild(elm.firstChild) diff --git a/src/platforms/web/security.js b/src/platforms/web/security.js new file mode 100644 index 00000000000..ae0ffa19bbc --- /dev/null +++ b/src/platforms/web/security.js @@ -0,0 +1,41 @@ +/* @flow */ +import Vue from 'core/index' +import {getTrustedTypes, isTrustedValue} from 'shared/util' + +type TrustedTypePolicy = { + // value returned is actually an object with toString method returning the wrapped value + createHTML: (value: any) => string; +}; + +let policy: ?TrustedTypePolicy +// create policy lazily to simplify testing +function getOrCreatePolicy() { + const tt = getTrustedTypes() + if (tt && !policy) { + policy = tt.createPolicy(Vue.config.trustedTypesPolicyName, {createHTML: (s) => s}); + } + + return policy +} + +if (process.env.NODE_ENV !== 'production') { + // we need this function to clear the policy in tests + Vue.prototype.$clearTrustedTypesPolicy = function() { + policy = undefined + } +} + +export function maybeCreateDangerousSvgHTML(value: any): string { + const tt = getTrustedTypes() + + if (!tt || !isTrustedValue(value)) return `${value}`; + // flow complains that 'getOrCreatePolicy()' might return null + else return (getOrCreatePolicy(): any).createHTML(`${value}`); +} + +export function getTrustedShouldDecodeInnerHTML(href: boolean): string { + const html = href ? `` : `
`; + const p = getOrCreatePolicy() + if (!p) return html; + else return p.createHTML(html); +} \ No newline at end of file diff --git a/src/platforms/web/util/compat.js b/src/platforms/web/util/compat.js index d95759cce31..a95339778a6 100644 --- a/src/platforms/web/util/compat.js +++ b/src/platforms/web/util/compat.js @@ -1,12 +1,13 @@ /* @flow */ import { inBrowser } from 'core/util/index' +import {getTrustedShouldDecodeInnerHTML} from 'web/security' // check whether current browser encodes a char inside attribute values let div function getShouldDecode (href: boolean): boolean { div = div || document.createElement('div') - div.innerHTML = href ? `` : `
` + div.innerHTML = getTrustedShouldDecodeInnerHTML(href) return div.innerHTML.indexOf(' ') > 0 } diff --git a/src/shared/util.js b/src/shared/util.js index 9f240c77b14..733f3e0a0e8 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -79,15 +79,35 @@ export function isPromise (val: any): boolean { ) } +let trustedTypes = undefined +export function getTrustedTypes() { + if (trustedTypes === undefined) { + // TrustedTypes have been renamed to trustedTypes https://github.com/WICG/trusted-types/issues/177 + trustedTypes = typeof window !== 'undefined' ? (window.trustedTypes || window.TrustedTypes) : null; + } + return trustedTypes; +} + +export function isTrustedValue(value: any): boolean { + const tt = getTrustedTypes(); + if (!tt) return false; + // TrustedURLs are deprecated and will be removed soon: https://github.com/WICG/trusted-types/pull/204 + else return tt.isHTML(value) || tt.isScript(value) || tt.isScriptURL(value) || (tt.isURL && tt.isURL(value)) +} + /** * Convert a value to a string that is actually rendered. */ export function toString (val: any): string { - return val == null - ? '' - : Array.isArray(val) || (isPlainObject(val) && val.toString === _toString) - ? JSON.stringify(val, null, 2) - : String(val) + if (isTrustedValue(val)) { + return val; + } else { + return val == null + ? '' + : Array.isArray(val) || (isPlainObject(val) && val.toString === _toString) + ? JSON.stringify(val, null, 2) + : String(val) + } } /** diff --git a/test/unit/features/trusted-types.spec.js b/test/unit/features/trusted-types.spec.js new file mode 100644 index 00000000000..c2b592f0572 --- /dev/null +++ b/test/unit/features/trusted-types.spec.js @@ -0,0 +1,321 @@ +// NOTE: We emulate trusted types behaviour such that the tests +// are deterministic. These tests needs to be updated if the trusted +// types API changes. +// +// You can find trusted types repository here: +// https://github.com/WICG/trusted-types +// +// TODO: replace testing setup with polyfill, once it exports +// enforcing API. + +import Vue from 'vue' + +// we don't differentiate between different types of trusted values +const createTrustedValue = (value) => ({toString: () => value, isTrusted: true}) +const isTrustedValue = (value) => value && value.isTrusted + +const unsafeHtml = ''; +const unsafeScript = 'alert(0)'; + +describe('rendering with trusted types enforced', () => { + let descriptorEntries = []; + let setAttributeDescriptor; + let policy; + // NOTE: trusted type error is not propagated from v-html directive and application will not + // render the dangerous html, but will continue rendering other components. If the error is + // thrown by unsafe setAttribute call (e.g. srcdoc in iframe) the rendering fails completely. + // We log the errors, before throwing so we can be sure that trusted types work. + let errorLog; + let vuePolicyName; + + function emulateSetAttribute() { + // enforce trusted values only on properties in this array + const unsafeAttributeList = ['srcdoc', 'onclick']; + setAttributeDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'setAttribute'); + Object.defineProperty(Element.prototype, 'setAttribute', { + value: function(name, value) { + let args = [name, value]; + unsafeAttributeList.forEach((attr) => { + if (attr === name) { + if (isTrustedValue(value)) { + args = [name, value.toString()]; + } else { + errorLog.push(createTTErrorMessage(attr, value)); + throw new Error(value); + } + } + }); + setAttributeDescriptor.value.apply(this, args); + } + }); + } + + function emulateTrustedTypesOnProperty(object, prop) { + const desc = Object.getOwnPropertyDescriptor(object, prop); + descriptorEntries.push({object, prop, desc}); + Object.defineProperty(object, prop, { + set: function(value) { + console.log('set', value, prop); + if (isTrustedValue(value)) { + desc.set.apply(this, [value.toString()]); + } else { + errorLog.push(createTTErrorMessage(prop, value)); + throw new Error(value); + } + }, + }); + } + + function removeAllTrustedTypesEmulation() { + descriptorEntries.forEach(({object, prop, desc}) => { + Object.defineProperty(object, prop, desc); + }); + descriptorEntries = []; + + Object.defineProperty( + Element.prototype, 'setAttribute', setAttributeDescriptor); + } + + function createTTErrorMessage(name, value) { + return `TT ERROR: ${name} ${value}`; + } + + beforeEach(() => { + window.trustedTypes = { + createPolicy: (name) => { + // capture the name of the vue policy so we can test it. Relies on fact + // that there are only 2 policies (for vue and for tests). + if (name !== 'test-policy') { + vuePolicyName = name; + } + return { + createHTML: createTrustedValue, + createScript: createTrustedValue, + createScriptURL: createTrustedValue, + }; + }, + isHTML: (v) => isTrustedValue(v), + isScript: (v) => isTrustedValue(v), + isScriptURL: (v) => isTrustedValue(v), + }; + + emulateTrustedTypesOnProperty(Element.prototype, 'innerHTML'); + emulateTrustedTypesOnProperty(HTMLIFrameElement.prototype, 'srcdoc'); + emulateSetAttribute(); + + // TODO: this needs to be changed once we use trusted types polyfill + policy = window.trustedTypes.createPolicy('test-policy'); + + errorLog = []; + vuePolicyName = ''; + }); + + afterEach(() => { + removeAllTrustedTypesEmulation(); + delete window.trustedTypes; + }); + + it('Trusted types emulation works', () => { + const el = document.createElement('div'); + expect(el.innerHTML).toBe(''); + el.innerHTML = policy.createHTML('val'); + expect(el.innerHTML, 'val'); + + expect(() => { + el.innerHTML = 'val'; + }).toThrow(); + }); + + describe('vue policy', () => { + let innerHTMLDescriptor; + + // simulate svg elements in Internet Explorer which don't have 'innerHTML' property + beforeEach(() => { + innerHTMLDescriptor = Object.getOwnPropertyDescriptor( + Element.prototype, + 'innerHTML', + ); + delete Element.prototype.innerHTML; + Object.defineProperty( + HTMLDivElement.prototype, + 'innerHTML', + innerHTMLDescriptor, + ); + }); + + afterEach(() => { + Vue.prototype.$clearTrustedTypesPolicy(); + + delete HTMLDivElement.prototype.innerHTML; + Object.defineProperty( + Element.prototype, + 'innerHTML', + innerHTMLDescriptor, + ); + }); + + it('uses default policy name "vue"', () => { + // we need to trigger creation of vue policy + const vm = new Vue({ + render: (c) => { + return c('svg', { + domProps: { + innerHTML: policy.createHTML('safe html'), + }, + }); + } + }) + + vm.$mount(); + expect(vuePolicyName).toBe('vue'); + }); + + it('policy name can be configured', () => { + Vue.config.trustedTypesPolicyName = 'userProvidedPolicyName'; + + // we need to trigger creation of vue policy + const vm = new Vue({ + render: (c) => { + return c('svg', { + domProps: { + innerHTML: policy.createHTML('safe html'), + }, + }); + } + }) + + vm.$mount(); + expect(vuePolicyName).toBe('userProvidedPolicyName'); + }); + + it('will throw an error on untrusted html', () => { + const vm = new Vue({ + render: (c) => { + return c('svg', { + domProps: { + innerHTML: unsafeHtml, + }, + }); + } + }) + + expect(() => { + vm.$mount(); + }).toThrow(); + expect(errorLog).toEqual([createTTErrorMessage('innerHTML', `${unsafeHtml}`)]); + }); + + it('passes if payload is TrustedHTML', () => { + const vm = new Vue({ + render: (c) => { + return c('svg', { + domProps: { + innerHTML: policy.createHTML('safe html'), + }, + }); + } + }) + + vm.$mount(); + expect(vm.$el.textContent).toBe('safe html'); + }); + }); + + // html interpolation is safe because it's put into DOM as text node + it('interpolation is trusted', () => { + const vm = new Vue({ + data: { + unsafeHtml, + }, + template: '
{{unsafeHtml}}
' + }) + + vm.$mount(); + expect(vm.$el.textContent).toBe(document.createTextNode(unsafeHtml).textContent); + }); + + describe('throws on untrusted values', () => { + it('v-html directive', () => { + const vm = new Vue({ + data: { + unsafeHtml, + }, + template: '
' + }) + + vm.$mount(); + expect(errorLog).toEqual([createTTErrorMessage('innerHTML', unsafeHtml)]); + }); + + it('attribute interpolation', () => { + const vm = new Vue({ + data: { + unsafeHtml, + }, + template: '' + }) + + expect(() => { + vm.$mount(); + }).toThrow(); + expect(errorLog).toEqual([createTTErrorMessage('srcdoc', unsafeHtml)]); + }); + + it('on* events', () => { + const vm = new Vue({ + data: { + unsafeScript, + }, + template: '' + }) + + expect(() => { + vm.$mount(); + }).toThrow(); + expect(errorLog).toEqual([createTTErrorMessage('onclick', unsafeScript)]); + }); + }); + + describe('runs without error on trusted values', () => { + it('v-html directive', () => { + const vm = new Vue({ + data: { + safeHtml: policy.createHTML('safeHtmlValue'), + }, + template: '
' + }) + + vm.$mount(); + expect(vm.$el.innerHTML).toBe('safeHtmlValue'); + expect(errorLog).toEqual([]); + }); + + it('attribute interpolation', () => { + const vm = new Vue({ + data: { + safeScript: policy.createScript('safeScriptValue'), + }, + template: '' + }) + + vm.$mount(); + expect(vm.$el.srcdoc).toBe('safeScriptValue'); + expect(errorLog).toEqual([]); + }); + + it('on* events', () => { + const vm = new Vue({ + data: { + safeScript: policy.createScript('safeScriptValue'), + }, + template: '' + }) + + vm.$mount(); + const onClickFn = vm.$el.onclick.toString(); + const onClickBody = onClickFn.substring(onClickFn.indexOf("{") + 1, onClickFn.lastIndexOf("}")); + expect(onClickBody.trim()).toBe('safeScriptValue'); + expect(errorLog).toEqual([]); + }); + }); +});