Skip to content

Commit

Permalink
fix(material/icon): make icon-registry compatible with Trusted Types (#…
Browse files Browse the repository at this point in the history
…23140)

When Angular Material is used in an environment that enforces Trusted
Types, the icon registry raises a Trusted Types violation due to its use
of element.innerHTML when initializing SVG icons.

To make the icon registry compatible with Trusted Types,
SvgIconConfig.svgText is changed to a TrustedHTML, and its users updated
to either produce TrustedHTML (making sure to only do so in cases where
its security can be readily assessed) or pass such values along.

To facilitate this, add a module that provides a Trusted Types policy,
'angular#components'. The policy is created lazily and stored in a
module-local variable. This is the same as the approach taken by Angular
proper in
https://github.com/angular/angular/blob/master/packages/core/src/util/security/trusted_types.ts
  • Loading branch information
bjarkler committed Sep 3, 2021
1 parent 2c48fc8 commit 881edec
Show file tree
Hide file tree
Showing 2 changed files with 92 additions and 13 deletions.
36 changes: 23 additions & 13 deletions src/material/icon/icon-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import {DomSanitizer, SafeResourceUrl, SafeHtml} from '@angular/platform-browser';
import {forkJoin, Observable, of as observableOf, throwError as observableThrow} from 'rxjs';
import {catchError, finalize, map, share, tap} from 'rxjs/operators';
import {TrustedHTML, trustedHTMLFromString} from './trusted-types';


/**
Expand Down Expand Up @@ -96,12 +97,12 @@ class SvgIconConfig {

constructor(
public url: SafeResourceUrl,
public svgText: string | null,
public svgText: TrustedHTML | null,
public options?: IconOptions) {}
}

/** Icon configuration whose content has already been loaded. */
type LoadedSvgIconConfig = SvgIconConfig & {svgText: string};
type LoadedSvgIconConfig = SvgIconConfig & {svgText: TrustedHTML};

/**
* Service to register and display icons used by the `<mat-icon>` component.
Expand Down Expand Up @@ -129,7 +130,7 @@ export class MatIconRegistry implements OnDestroy {
private _cachedIconsByUrl = new Map<string, SVGElement>();

/** In-progress icon fetches. Used to coalesce multiple requests to the same URL. */
private _inProgressUrlFetches = new Map<string, Observable<string>>();
private _inProgressUrlFetches = new Map<string, Observable<TrustedHTML>>();

/** Map from font identifiers to their CSS class names. Used for icon fonts. */
private _fontCssClassesByAlias = new Map<string, string>();
Expand Down Expand Up @@ -209,8 +210,10 @@ export class MatIconRegistry implements OnDestroy {
throw getMatIconFailedToSanitizeLiteralError(literal);
}

// Security: The literal is passed in as SafeHtml, and is thus trusted.
const trustedLiteral = trustedHTMLFromString(cleanLiteral);
return this._addSvgIconConfig(namespace, iconName,
new SvgIconConfig('', cleanLiteral, options));
new SvgIconConfig('', trustedLiteral, options));
}

/**
Expand Down Expand Up @@ -251,7 +254,9 @@ export class MatIconRegistry implements OnDestroy {
throw getMatIconFailedToSanitizeLiteralError(literal);
}

return this._addSvgIconSetConfig(namespace, new SvgIconConfig('', cleanLiteral, options));
// Security: The literal is passed in as SafeHtml, and is thus trusted.
const trustedLiteral = trustedHTMLFromString(cleanLiteral);
return this._addSvgIconSetConfig(namespace, new SvgIconConfig('', trustedLiteral, options));
}

/**
Expand Down Expand Up @@ -399,7 +404,7 @@ export class MatIconRegistry implements OnDestroy {

// Not found in any cached icon sets. If there are icon sets with URLs that we haven't
// fetched, fetch them now and look for iconName in the results.
const iconSetFetchRequests: Observable<string | null>[] = iconSetConfigs
const iconSetFetchRequests: Observable<TrustedHTML | null>[] = iconSetConfigs
.filter(iconSetConfig => !iconSetConfig.svgText)
.map(iconSetConfig => {
return this._loadSvgIconSetFromConfig(iconSetConfig).pipe(
Expand Down Expand Up @@ -444,7 +449,7 @@ export class MatIconRegistry implements OnDestroy {
// the parsing by doing a quick check using `indexOf` to see if there's any chance for the
// icon to be in the set. This won't be 100% accurate, but it should help us avoid at least
// some of the parsing.
if (config.svgText && config.svgText.indexOf(iconName) > -1) {
if (config.svgText && config.svgText.toString().indexOf(iconName) > -1) {
const svg = this._svgElementFromConfig(config as LoadedSvgIconConfig);
const foundIcon = this._extractSvgIconFromSet(svg, iconName, config.options);
if (foundIcon) {
Expand All @@ -470,7 +475,7 @@ export class MatIconRegistry implements OnDestroy {
* Loads the content of the icon set URL specified in the
* SvgIconConfig and attaches it to the config.
*/
private _loadSvgIconSetFromConfig(config: SvgIconConfig): Observable<string | null> {
private _loadSvgIconSetFromConfig(config: SvgIconConfig): Observable<TrustedHTML | null> {
if (config.svgText) {
return observableOf(null);
}
Expand Down Expand Up @@ -516,7 +521,7 @@ export class MatIconRegistry implements OnDestroy {
// have to create an empty SVG node using innerHTML and append its content.
// Elements created using DOMParser.parseFromString have the same problem.
// http://stackoverflow.com/questions/23003278/svg-innerhtml-in-firefox-can-not-display
const svg = this._svgElementFromString('<svg></svg>');
const svg = this._svgElementFromString(trustedHTMLFromString('<svg></svg>'));
// Clone the node so we don't remove it from the parent icon set element.
svg.appendChild(iconElement);

Expand All @@ -526,9 +531,9 @@ export class MatIconRegistry implements OnDestroy {
/**
* Creates a DOM element from the given SVG string.
*/
private _svgElementFromString(str: string): SVGElement {
private _svgElementFromString(str: TrustedHTML): SVGElement {
const div = this._document.createElement('DIV');
div.innerHTML = str;
div.innerHTML = str as unknown as string;
const svg = div.querySelector('svg') as SVGElement;

// TODO: add an ngDevMode check
Expand All @@ -543,7 +548,7 @@ export class MatIconRegistry implements OnDestroy {
* Converts an element into an SVG node by cloning all of its children.
*/
private _toSvgElement(element: Element): SVGElement {
const svg = this._svgElementFromString('<svg></svg>');
const svg = this._svgElementFromString(trustedHTMLFromString('<svg></svg>'));
const attributes = element.attributes;

// Copy over all the attributes from the `symbol` to the new SVG, except the id.
Expand Down Expand Up @@ -585,7 +590,7 @@ export class MatIconRegistry implements OnDestroy {
* Returns an Observable which produces the string contents of the given icon. Results may be
* cached, so future calls with the same URL may not cause another HTTP request.
*/
private _fetchIcon(iconConfig: SvgIconConfig): Observable<string> {
private _fetchIcon(iconConfig: SvgIconConfig): Observable<TrustedHTML> {
const {url: safeUrl, options} = iconConfig;
const withCredentials = options?.withCredentials ?? false;

Expand Down Expand Up @@ -615,6 +620,11 @@ export class MatIconRegistry implements OnDestroy {
}

const req = this._httpClient.get(url, {responseType: 'text', withCredentials}).pipe(
map(svg => {
// Security: This SVG is fetched from a SafeResourceUrl, and is thus
// trusted HTML.
return trustedHTMLFromString(svg);
}),
finalize(() => this._inProgressUrlFetches.delete(url)),
share(),
);
Expand Down
69 changes: 69 additions & 0 deletions src/material/icon/trusted-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

/**
* @fileoverview
* A module to facilitate use of a Trusted Types policy internally within
* Angular Material. It lazily constructs the Trusted Types policy, providing
* helper utilities for promoting strings to Trusted Types. When Trusted Types
* are not available, strings are used as a fallback.
* @security All use of this module is security-sensitive and should go through
* security review.
*/

export declare interface TrustedHTML {
__brand__: 'TrustedHTML';
}

export declare interface TrustedTypePolicyFactory {
createPolicy(policyName: string, policyOptions: {
createHTML?: (input: string) => string,
}): TrustedTypePolicy;
}

export declare interface TrustedTypePolicy {
createHTML(input: string): TrustedHTML;
}

/**
* The Trusted Types policy, or null if Trusted Types are not
* enabled/supported, or undefined if the policy has not been created yet.
*/
let policy: TrustedTypePolicy|null|undefined;

/**
* Returns the Trusted Types policy, or null if Trusted Types are not
* enabled/supported. The first call to this function will create the policy.
*/
function getPolicy(): TrustedTypePolicy|null {
if (policy === undefined) {
policy = null;
if (typeof window !== 'undefined') {
const ttWindow = window as unknown as {trustedTypes?: TrustedTypePolicyFactory};
if (ttWindow.trustedTypes !== undefined) {
policy = ttWindow.trustedTypes.createPolicy('angular#components', {
createHTML: (s: string) => s,
});
}
}
}
return policy;
}

/**
* Unsafely promote a string to a TrustedHTML, falling back to strings when
* Trusted Types are not available.
* @security This is a security-sensitive function; any use of this function
* must go through security review. In particular, it must be assured that the
* provided string will never cause an XSS vulnerability if used in a context
* that will be interpreted as HTML by a browser, e.g. when assigning to
* element.innerHTML.
*/
export function trustedHTMLFromString(html: string): TrustedHTML {
return getPolicy()?.createHTML(html) || html as unknown as TrustedHTML;
}

0 comments on commit 881edec

Please sign in to comment.