Skip to content

Commit

Permalink
feat(web): Integrate trusted types into Vue
Browse files Browse the repository at this point in the history
  • Loading branch information
Emanuel Tesar committed Sep 6, 2019
1 parent 399b536 commit 7a443c5
Show file tree
Hide file tree
Showing 5 changed files with 259 additions and 7 deletions.
4 changes: 3 additions & 1 deletion src/platforms/web/runtime/modules/dom-props.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { isDef, isUndef, extend, toNumber } from 'shared/util'
import { isSVG } from 'web/util/index'
import {convertToTrustedType} from 'web/security'

let svgContainer

Expand All @@ -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] = ''
}
}
Expand Down Expand Up @@ -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 = `<svg>${cur}</svg>`
svgContainer.innerHTML = convertToTrustedType(`<svg>${cur}</svg>`)
const svg = svgContainer.firstChild
while (elm.firstChild) {
elm.removeChild(elm.firstChild)
Expand Down
23 changes: 23 additions & 0 deletions src/platforms/web/security.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/* @flow */

type TrustedTypePolicy = {
// value returned is actually an object with toString method returning the wrapped value
createHTML: (value: any) => string;
};

let policy: TrustedTypePolicy
export function convertToTrustedType(value: any) {
// create policy lazily to simplify testing
const tt = getTrustedTypes()
if (tt && !policy) {
policy = tt.createPolicy('vue', {createHTML: (s) => s});
}

if (!tt) return value;
else return policy.createHTML(value);
}

export function getTrustedTypes() {
// TrustedTypes have been renamed to trustedTypes https://github.com/WICG/trusted-types/issues/177
return window.trustedTypes || window.TrustedTypes;
}
3 changes: 2 additions & 1 deletion src/platforms/web/util/compat.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
/* @flow */

import { inBrowser } from 'core/util/index'
import {convertToTrustedType} 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 ? `<a href="\n"/>` : `<div a="\n"/>`
div.innerHTML = convertToTrustedType(href ? `<a href="\n"/>` : `<div a="\n"/>`)
return div.innerHTML.indexOf('&#10;') > 0
}

Expand Down
17 changes: 12 additions & 5 deletions src/shared/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,18 @@ export function isPromise (val: any): boolean {
* 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)
// TrustedTypes have been renamed to trustedTypes https://github.com/WICG/trusted-types/issues/177
const tt = window.trustedTypes || window.TrustedTypes;
// TrustedURLs are deprecated and will be removed soon: https://github.com/WICG/trusted-types/pull/204
if (tt && (tt.isHTML(val) || tt.isScript(val) || tt.isScriptURL(val) || (tt.isURL && tt.isURL(val)))) {
return val;
} else {
return val == null
? ''
: Array.isArray(val) || (isPlainObject(val) && val.toString === _toString)
? JSON.stringify(val, null, 2)
: String(val)
}
}

/**
Expand Down
219 changes: 219 additions & 0 deletions test/unit/features/trusted-types.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// 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) => `TRUSTED${value}`;
const isTrustedValue = (value) => value.startsWith('TRUSTED');
const unwrapTrustedValue = (value) => value.substr('TRUSTED'.length);

const unsafeHtml = '<img src=x onerror="alert(0)">';
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;

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, unwrapTrustedValue(value)];
} 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) {
if (isTrustedValue(value)) {
desc.set.apply(this, [unwrapTrustedValue(value)]);
} 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: () => {
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();

errorLog = [];
});

afterEach(() => {
removeAllTrustedTypesEmulation();
delete window.trustedTypes;
});

it('Trusted types emulation works', () => {
const el = document.createElement('div');
expect(el.innerHTML).toBe('');
el.innerHTML = policy.createHTML('<span>val</span>');
expect(el.innerHTML, '<span>val</span>');

expect(() => {
el.innerHTML = '<span>val</span>';
}).toThrow();
});

// html interpolation is safe because it's put into DOM as text node
it('interpolation is trusted', () => {
const vm = new Vue({
data: {
unsafeHtml,
},
template: '<div>{{unsafeHtml}}</div>'
})

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: '<div v-html="unsafeHtml"></div>'
})

vm.$mount();
expect(errorLog).toEqual([createTTErrorMessage('innerHTML', unsafeHtml)]);
});

it('attribute interpolation', () => {
const vm = new Vue({
data: {
unsafeHtml,
},
template: '<iframe :srcdoc="unsafeHtml"></iframe>'
})

expect(() => {
vm.$mount();
}).toThrow();
expect(errorLog).toEqual([createTTErrorMessage('srcdoc', unsafeHtml)]);
});

it('on* events', () => {
const vm = new Vue({
data: {
unsafeScript,
},
template: '<button :onclick="unsafeScript">unsafe btn</button>'
})

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: '<div v-html="safeHtml"></div>'
})

vm.$mount();
expect(vm.$el.innerHTML).toBe('safeHtmlValue');
expect(errorLog).toEqual([]);
});

it('attribute interpolation', () => {
const vm = new Vue({
data: {
safeScript: policy.createScript('safeScriptValue'),
},
template: '<iframe :srcdoc="safeScript"></iframe>'
})

vm.$mount();
expect(vm.$el.srcdoc).toBe('safeScriptValue');
expect(errorLog).toEqual([]);
});

it('on* events', () => {
const vm = new Vue({
data: {
safeScript: policy.createScript('safeScriptValue'),
},
template: '<button :onclick="safeScript">unsafe btn</button>'
})

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([]);
});
});
});

0 comments on commit 7a443c5

Please sign in to comment.