// Copyright 2012 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {assert, assertNotReached} from './assert.js';
export interface SanitizeInnerHtmlOpts {
substitutions?: string[];
attrs?: string[];
tags?: string[];
}
/**
* Make a string safe for Polymer bindings that are inner-h-t-m-l or other
* innerHTML use.
* @param rawString The unsanitized string
* @param opts Optional additional allowed tags and attributes.
*/
function sanitizeInnerHtmlInternal(
rawString: string, opts?: SanitizeInnerHtmlOpts): string {
opts = opts || {};
const html = parseHtmlSubset(`<b>${rawString}</b>`, opts.tags, opts.attrs)
.firstElementChild!;
return html.innerHTML;
}
// <if expr="not is_ios">
let sanitizedPolicy: TrustedTypePolicy|null = null;
/**
* Same as |sanitizeInnerHtmlInternal|, but it passes through sanitizedPolicy
* to create a TrustedHTML.
*/
export function sanitizeInnerHtml(
rawString: string, opts?: SanitizeInnerHtmlOpts): TrustedHTML {
assert(window.trustedTypes);
if (sanitizedPolicy === null) {
// Initialize |sanitizedPolicy| lazily.
sanitizedPolicy = window.trustedTypes.createPolicy('sanitize-inner-html', {
createHTML: sanitizeInnerHtmlInternal,
createScript: () => assertNotReached(),
createScriptURL: () => assertNotReached(),
});
}
return sanitizedPolicy.createHTML(rawString, opts);
}
// </if>
// <if expr="is_ios">
/**
* Delegates to sanitizeInnerHtmlInternal() since on iOS there is no
* window.trustedTypes support yet.
*/
export function sanitizeInnerHtml(
rawString: string, opts?: SanitizeInnerHtmlOpts): string {
assert(!window.trustedTypes);
return sanitizeInnerHtmlInternal(rawString, opts);
}
// </if>
type AllowFunction = (node: Node, value: string) => boolean;
const allowAttribute: AllowFunction = (_node, _value) => true;
/** Allow-list of attributes in parseHtmlSubset. */
const allowedAttributes: Map<string, AllowFunction> = new Map([
[
'href',
(node, value) => {
// Only allow a[href] starting with chrome:// or https:// or equaling
// to #.
return (node as HTMLElement).tagName === 'A' &&
(value.startsWith('chrome://') || value.startsWith('https://') ||
value === '#');
},
],
[
'target',
(node, value) => {
// Only allow a[target='_blank'].
// TODO(dbeam): are there valid use cases for target !== '_blank'?
return (node as HTMLElement).tagName === 'A' && value === '_blank';
},
],
]);
/** Allow-list of optional attributes in parseHtmlSubset. */
const allowedOptionalAttributes: Map<string, AllowFunction> = new Map([
['class', allowAttribute],
['id', allowAttribute],
['is', (_node, value) => value === 'action-link' || value === ''],
['role', (_node, value) => value === 'link'],
[
'src',
(node, value) => {
// Only allow img[src] starting with chrome://
return (node as HTMLElement).tagName === 'IMG' &&
value.startsWith('chrome://');
},
],
['tabindex', allowAttribute],
['aria-description', allowAttribute],
['aria-hidden', allowAttribute],
['aria-label', allowAttribute],
['aria-labelledby', allowAttribute],
]);
/** Allow-list of tag names in parseHtmlSubset. */
const allowedTags: Set<string> = new Set(
['A', 'B', 'I', 'BR', 'DIV', 'EM', 'KBD', 'P', 'PRE', 'SPAN', 'STRONG']);
/** Allow-list of optional tag names in parseHtmlSubset. */
const allowedOptionalTags: Set<string> = new Set(['IMG', 'LI', 'UL']);
/**
* This policy maps a given string to a `TrustedHTML` object
* without performing any validation. Callsites must ensure
* that the resulting object will only be used in inert
* documents. Initialized lazily.
*/
let unsanitizedPolicy: TrustedTypePolicy;
/**
* @param optTags an Array to merge.
* @return Set of allowed tags.
*/
function mergeTags(optTags: string[]): Set<string> {
const clone = new Set(allowedTags);
optTags.forEach(str => {
const tag = str.toUpperCase();
if (allowedOptionalTags.has(tag)) {
clone.add(tag);
}
});
return clone;
}
/**
* @param optAttrs an Array to merge.
* @return Map of allowed attributes.
*/
function mergeAttrs(optAttrs: string[]): Map<string, AllowFunction> {
const clone = new Map(allowedAttributes);
optAttrs.forEach(key => {
if (allowedOptionalAttributes.has(key)) {
clone.set(key, allowedOptionalAttributes.get(key)!);
}
});
return clone;
}
function walk(n: Node, f: (p: Node) => void) {
f(n);
for (let i = 0; i < n.childNodes.length; i++) {
walk(n.childNodes[i]!, f);
}
}
function assertElement(tags: Set<string>, node: Node) {
if (!tags.has((node as HTMLElement).tagName)) {
throw Error((node as HTMLElement).tagName + ' is not supported');
}
}
function assertAttribute(
attrs: Map<string, AllowFunction>, attrNode: Attr, node: Node) {
const n = attrNode.nodeName;
const v = attrNode.nodeValue || '';
if (!attrs.has(n) || !attrs.get(n)!(node, v)) {
throw Error(
(node as HTMLElement).tagName + '[' + n + '="' + v +
'"] is not supported');
}
}
/**
* Parses a very small subset of HTML. This ensures that insecure HTML /
* javascript cannot be injected into WebUI.
* @param s The string to parse.
* @param extraTags Optional extra allowed tags.
* @param extraAttrs
* Optional extra allowed attributes (all tags are run through these).
* @throws an Error in case of non supported markup.
* @return A document fragment containing the DOM tree.
*/
export function parseHtmlSubset(
s: string, extraTags?: string[], extraAttrs?: string[]): DocumentFragment {
const tags = extraTags ? mergeTags(extraTags) : allowedTags;
const attrs = extraAttrs ? mergeAttrs(extraAttrs) : allowedAttributes;
const doc = document.implementation.createHTMLDocument('');
const r = doc.createRange();
r.selectNode(doc.body);
if (window.trustedTypes) {
if (!unsanitizedPolicy) {
unsanitizedPolicy =
window.trustedTypes.createPolicy('parse-html-subset', {
createHTML: (untrustedHTML: string) => untrustedHTML,
createScript: () => assertNotReached(),
createScriptURL: () => assertNotReached(),
});
}
s = unsanitizedPolicy.createHTML(s) as unknown as string;
}
// This does not execute any scripts because the document has no view.
const df = r.createContextualFragment(s);
walk(df, function(node) {
switch (node.nodeType) {
case Node.ELEMENT_NODE:
assertElement(tags, node);
const nodeAttrs = (node as HTMLElement).attributes;
for (let i = 0; i < nodeAttrs.length; ++i) {
assertAttribute(attrs, nodeAttrs[i]!, node);
}
break;
case Node.COMMENT_NODE:
case Node.DOCUMENT_FRAGMENT_NODE:
case Node.TEXT_NODE:
break;
default:
throw Error('Node type ' + node.nodeType + ' is not supported');
}
});
return df;
}