Skip to content

fix(material/icon): make icon-registry compatible with Trusted Types #23140

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}