Skip to content

Commit

Permalink
feat(fast-element): add trusted types for HTML (microsoft#2855)
Browse files Browse the repository at this point in the history
  • Loading branch information
EisenbergEffect authored Apr 1, 2020
1 parent 05a597a commit 69d44a2
Show file tree
Hide file tree
Showing 6 changed files with 121 additions and 58 deletions.
19 changes: 17 additions & 2 deletions packages/fast-element/docs/building-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,10 +292,25 @@ Properties can also be set directly on an HTML element. To do so, prepend the pr
**Example: Inner HTML**

```HTML
<div :innerHTML=${x => sanitize(x.someDangerousHTMLContent)}></div>
<div :innerHTML=${x => x.someDangerousHTMLContent}></div>
```

> **WARNING:** Avoid scenarios that require you to directly set HTML, especially when the content is coming from an external source. If you must do this, always sanitize the HTML content using a robust HTML sanitizer library, represented by the use of the `sanitize` function above.
Avoid scenarios that require you to directly set HTML, especially when the content is coming from an external source. If you must do this, you should always sanitize the HTML. The best way to accomplish HTML sanitization is to configure [a trusted types policy](https://w3c.github.io/webappsec-trusted-types/dist/spec/) with FastElement's template engine. FastElement ensures that all HTML strings pass through the configured policy. Also, by leveraging the platform's trusted types capabilities, you get native enforcement of the policy through CSP headers. Here's an example of how to configure a custom policy to sanitize HTML:

```TypeScript
import { DOM } from '@microsoft/fast-element';

const myPolicy = trustedTypes.createPolicy('my-policy', {
createHTML(html) {
// TODO: invoke a sanitization library on the html before returning it
return html;
}
});

DOM.setHTMLPolicy(myPolicy);
```

> **IMPORTANT:** For security reasons, the HTML Policy can only be set once. For this reason, it should be set by application developers and not by component authors, and it should be done immediately during the startup sequence of the application.
#### Events

Expand Down
4 changes: 4 additions & 0 deletions packages/fast-element/src/directives/binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ export class BindingDirective extends Directive {
case ":":
this._cleanedTargetName = value.substr(1);
this.updateTarget = updatePropertyTarget;
if (this._cleanedTargetName === "innerHTML") {
const expression = this.expression;
this.expression = (s, c) => DOM.createHTML(expression(s, c));
}
break;
case "?":
this._cleanedTargetName = value.substr(1);
Expand Down
2 changes: 1 addition & 1 deletion packages/fast-element/src/directives/directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export class AttachedBehaviorDirective<T = any> extends Directive {
}

public createPlaceholder(index: number) {
return `${this.name}="${DOM.createInterpolationPlaceholder(index)}"`;
return DOM.createCustomAttributePlaceholder(this.name, index);
}

public createBehavior(target: any) {
Expand Down
36 changes: 34 additions & 2 deletions packages/fast-element/src/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,35 @@ const markerClass = `fast-${Math.random()
.substring(7)}`;
const updateQueue = [] as Callable[];

export const DOM = {
type TrustedTypesPolicy = { createHTML(html: string): string };

// Tiny API-only polyfill for trustedTypes
if (globalThis.trustedTypes === void 0) {
globalThis.trustedTypes = { createPolicy: (name, rules) => rules };
}

const fastHTMLPolicy: TrustedTypesPolicy = globalThis.trustedTypes.createPolicy(
"fast-html",
{
createHTML: html => html,
}
);

let htmlPolicy: TrustedTypesPolicy = fastHTMLPolicy;

export const DOM = Object.freeze({
setHTMLPolicy(policy: TrustedTypesPolicy) {
if (htmlPolicy !== fastHTMLPolicy) {
throw new Error("The HTML policy can only be set once.");
}

htmlPolicy = policy;
},

createHTML(html: string) {
return htmlPolicy.createHTML(html);
},

isMarker(node: Node): node is Comment {
return node.nodeType === 8 && (node as Comment).data.startsWith(markerClass);
},
Expand All @@ -17,6 +45,10 @@ export const DOM = {
return `@{${index}}`;
},

createCustomAttributePlaceholder(attributeName: string, index: number) {
return `${attributeName}="${this.createInterpolationPlaceholder(index)}"`;
},

createBlockPlaceholder(index: number) {
return `<!--${markerClass}:${index}-->`;
},
Expand All @@ -28,7 +60,7 @@ export const DOM = {

updateQueue.push(callable);
},
};
});

function processQueue() {
const capacity = 1024;
Expand Down
54 changes: 25 additions & 29 deletions packages/fast-element/src/template-compiler.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,20 @@
import { ExpressionContext } from "./interfaces";
import { HTMLTemplate } from "./template";
import { BehaviorFactory } from "./directives/behavior";
import { DOM } from "./dom";
import { BindingDirective } from "./directives/binding";
import { Directive, AttachedBehaviorDirective } from "./directives/directive";

type InlineDirective = BindingDirective | AttachedBehaviorDirective;

const compilationContext = { locatedDirectives: 0, targetIndex: -1 };

export function compileTemplate(
html: string | HTMLTemplateElement,
directives: Directive[]
): HTMLTemplate {
let element: HTMLTemplateElement;

if (typeof html === "string") {
element = document.createElement("template");
element.innerHTML = html;

const fec = element.content.firstElementChild;

if (fec !== null && fec.tagName === "TEMPLATE") {
element = fec as HTMLTemplateElement;
}
} else {
element = html;
}

const hostFactories: BehaviorFactory[] = [];
export function compileTemplate(template: HTMLTemplateElement, directives: Directive[]) {
const hostBehaviorFactories: BehaviorFactory[] = [];

compilationContext.locatedDirectives = 0;
compileAttributes(element, directives, hostFactories, true);
compileAttributes(template, directives, hostBehaviorFactories, true);

const fragment = element.content;
const viewFactories: BehaviorFactory[] = [];
const fragment = template.content;
const viewBehaviorFactories: BehaviorFactory[] = [];
const directiveCount = directives.length;
const walker = document.createTreeWalker(
fragment,
Expand All @@ -56,7 +36,7 @@ export function compileTemplate(

switch (node.nodeType) {
case 1: // element node
compileAttributes(node as HTMLElement, directives, viewFactories);
compileAttributes(node as HTMLElement, directives, viewBehaviorFactories);
break;
case 3: // text node
// use wholeText to retrieve the textContent of all adjacent text nodes.
Expand All @@ -68,7 +48,7 @@ export function compileTemplate(
if (directive !== null) {
node.textContent = " ";
directive.makeIntoTextBinding();
viewFactories.push(directive);
viewBehaviorFactories.push(directive);
directive.targetIndex = compilationContext.targetIndex;

//remove adjacent text nodes.
Expand All @@ -84,15 +64,31 @@ export function compileTemplate(
directives[DOM.extractDirectiveIndexFromMarker(node)];
directive.targetIndex = compilationContext.targetIndex;
compilationContext.locatedDirectives++;
viewFactories.push(directive);
viewBehaviorFactories.push(directive);
} else {
node.parentNode!.removeChild(node);
compilationContext.targetIndex--;
}
}
}

return new HTMLTemplate(element, viewFactories, hostFactories);
let targetOffset = 0;

if (DOM.isMarker(fragment.firstChild!)) {
// If the first node in a fragment is a marker, that means it's an unstable first node,
// because something like a when, repeat, etc. could add nodes before the marker.
// To mitigate this, we insert a stable first node. However, if we insert a node,
// that will alter the result of the TreeWalker. So, we also need to offset the target index.
fragment.insertBefore(document.createComment(""), fragment.firstChild);
targetOffset = -1;
}

return {
fragment,
viewBehaviorFactories,
hostBehaviorFactories,
targetOffset,
};
}

function compileAttributes(
Expand Down
64 changes: 40 additions & 24 deletions packages/fast-element/src/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,38 +14,54 @@ export interface SyntheticViewTemplate {
create(): SyntheticView;
}

export class HTMLTemplate extends Directive
export class ViewTemplate extends Directive
implements ElementViewTemplate, SyntheticViewTemplate {
public createPlaceholder = DOM.createBlockPlaceholder;
private behaviorCount: number;
private hasHostBehaviors: boolean;
private fragment: DocumentFragment;
private behaviorCount: number = 0;
private hasHostBehaviors: boolean = false;
private fragment: DocumentFragment | null = null;
private targetOffset = 0;
private viewBehaviorFactories: BehaviorFactory[] | null = null;
private hostBehaviorFactories: BehaviorFactory[] | null = null;

constructor(
{ content: fragment }: HTMLTemplateElement,
private viewBehaviorFactories: BehaviorFactory[],
private hostBehaviorFactories: BehaviorFactory[]
private html: string | HTMLTemplateElement,
private directives: Directive[]
) {
super();

this.fragment = fragment;
this.behaviorCount =
this.viewBehaviorFactories.length + this.hostBehaviorFactories.length;
this.hasHostBehaviors = this.hostBehaviorFactories.length > 0;

if (DOM.isMarker(fragment.firstChild!)) {
// If the first node in a fragment is a marker, that means it's an unstable first node,
// because something like a when, repeat, etc. could add nodes before the marker.
// To mitigate this, we insert a stable first node. However, if we insert a node,
// that will alter the result of the TreeWalker. So, we also need to offset the target index.
fragment.insertBefore(document.createComment(""), fragment.firstChild);
this.targetOffset = -1;
}
}

public create(host?: Element) {
if (this.fragment === null) {
let template: HTMLTemplateElement;
const html = this.html;

if (typeof html === "string") {
template = document.createElement("template");
template.innerHTML = DOM.createHTML(html);

const fec = template.content.firstElementChild;

if (fec !== null && fec.tagName === "TEMPLATE") {
template = fec as HTMLTemplateElement;
}
} else {
template = html;
}

const result = compileTemplate(template, this.directives);

this.fragment = result.fragment;
this.viewBehaviorFactories = result.viewBehaviorFactories;
this.hostBehaviorFactories = result.hostBehaviorFactories;
this.targetOffset = result.targetOffset;
this.behaviorCount =
this.viewBehaviorFactories.length + this.hostBehaviorFactories.length;
this.hasHostBehaviors = this.hostBehaviorFactories.length > 0;
}

const fragment = this.fragment.cloneNode(true) as DocumentFragment;
const viewFactories = this.viewBehaviorFactories;
const viewFactories = this.viewBehaviorFactories!;
const behaviors = new Array<Behavior>(this.behaviorCount);
const walker = document.createTreeWalker(
fragment,
Expand Down Expand Up @@ -74,7 +90,7 @@ export class HTMLTemplate extends Directive
}

if (this.hasHostBehaviors) {
const hostFactories = this.hostBehaviorFactories;
const hostFactories = this.hostBehaviorFactories!;

for (let i = 0, ii = hostFactories.length; i < ii; ++i, ++behaviorIndex) {
behaviors[behaviorIndex] = hostFactories[i].createBehavior(host);
Expand Down Expand Up @@ -141,7 +157,7 @@ export function html<T = any>(

html += strings[strings.length - 1];

return compileTemplate(html, directives);
return new ViewTemplate(html, directives);
}

// Much thanks to LitHTML for working this out!
Expand Down

0 comments on commit 69d44a2

Please sign in to comment.