diff --git a/packages/elements/src/flag/__test__/flag.test.js b/packages/elements/src/flag/__test__/flag.test.js index eb29c2adbd..980fcafa1a 100644 --- a/packages/elements/src/flag/__test__/flag.test.js +++ b/packages/elements/src/flag/__test__/flag.test.js @@ -225,7 +225,7 @@ describe('flag/Flag', () => { expect(checkRequestedUrl(server.requests, uniqueInvalidFlagSrc)).to.equal(true, 'should try to request invalid flag'); expect(preloadedFlags[0].length > 0).to.equal(true, 'Should successfully preload flag by name with CDN prefix'); expect(preloadedFlags[1].length > 0).to.equal(true, 'Should successfully preload flags with src'); - expect(preloadedFlags[2].length === 0).to.equal(true, 'Should not preload invalid flag'); + expect(preloadedFlags[2], 'Should not preload invalid flag').to.be.undefined; el.setAttribute('flag', firstUniqueFlag); await elementUpdated(el); diff --git a/packages/elements/src/flag/utils/FlagLoader.ts b/packages/elements/src/flag/utils/FlagLoader.ts index a10f914db6..5ea67b666b 100644 --- a/packages/elements/src/flag/utils/FlagLoader.ts +++ b/packages/elements/src/flag/utils/FlagLoader.ts @@ -1,82 +1,10 @@ -import { CdnLoader, Deferred } from '@refinitiv-ui/utils/loader.js'; -const isUrl = (str: string): boolean => (/^https?:\/\//i).test(str); +import { SVGLoader } from '@refinitiv-ui/utils/loader.js'; /** * Caches and provides flag SVGs, Loaded either by name from CDN or directly by URL. * Uses singleton pattern */ -class FlagLoader extends CdnLoader { - private cdnPrefix = new Deferred(); - - private _isPrefixSet = false; - - /** - * @returns {boolean} clarify whether prefix has been set or not. - */ - public get isPrefixSet (): boolean { - return this._isPrefixSet; - } - - /** - * Sets clarify whether prefix has been set or not - * @param value - new value that is going to set. - */ - public set isPrefixSet (value: boolean) { - if (this._isPrefixSet !== value) { - this._isPrefixSet = value; - } - } - - /** - * @returns promise, which will be resolved with CDN prefix, once set. - */ - public getCdnPrefix (): Promise { - return this.cdnPrefix.promise; - } - - /** - * Sets CDN prefix to load source. - * Resolves deferred promise with CDN prefix and sets src used to check whether prefix is already set or not. - * @param prefix - CDN prefix. - * @returns {void} - */ - public setCdnPrefix (prefix: string): void { - if (prefix) { - this.cdnPrefix.resolve(prefix); - this.isPrefixSet = true; - } - } - - /** - * Creates complete source using CDN prefix and src. - * Waits for CDN prefix to be set. - * @param flagName - resource path for download - * @returns Promise, which will be resolved with complete source. - */ - public async getSrc (flagName: string): Promise { - return flagName ? `${await this.getCdnPrefix()}${flagName}.svg` : ''; - } - - public async loadSVG (flag: string): Promise { - if (flag) { - if (!isUrl(flag)) { - flag = await this.getSrc(flag); - } - const response = await this.load(flag); - if (response && response.status === 200 && response.getResponseHeader('content-type') === 'image/svg+xml') { - const container = document.createElement('svg'); - container.innerHTML = response.responseText; - this.stripUnsafeNodes(...container.children); - const svgRoot = container.firstElementChild as SVGElement | null; - if (svgRoot) { - svgRoot.setAttribute('focusable', 'false'); /* disable IE11 focus on SVG root element */ - } - return Promise.resolve(container.innerHTML); - } - return Promise.resolve(''); - } - } -} +class FlagLoader extends SVGLoader {} const flagLoaderInstance = new FlagLoader(); diff --git a/packages/elements/src/icon/__test__/icon.test.js b/packages/elements/src/icon/__test__/icon.test.js index f7ca9e89f3..bec5c2f1ff 100644 --- a/packages/elements/src/icon/__test__/icon.test.js +++ b/packages/elements/src/icon/__test__/icon.test.js @@ -236,7 +236,7 @@ describe('icon/Icon', () => { expect(checkRequestedUrl(server.requests, uniqueInvalidIconSrc)).to.equal(true, 'should try to request invalid icon'); expect(preloadedIcons[0].length > 0).to.equal(true, 'Should successfully preload icon by name with CDN prefix'); expect(preloadedIcons[1].length > 0).to.equal(true, 'Should successfully preload icons with src'); - expect(preloadedIcons[2].length === 0).to.equal(true, 'Should not preload invalid icon'); + expect(preloadedIcons[2], 'Should not preload invalid icon').to.be.undefined; el.setAttribute('icon', firstUniqueIcon); await elementUpdated(el); diff --git a/packages/elements/src/icon/utils/IconLoader.ts b/packages/elements/src/icon/utils/IconLoader.ts index 2e5e457efd..66ab68c6e1 100644 --- a/packages/elements/src/icon/utils/IconLoader.ts +++ b/packages/elements/src/icon/utils/IconLoader.ts @@ -1,89 +1,10 @@ -import { CdnLoader, Deferred } from '@refinitiv-ui/utils/loader.js'; -const isUrl = (str: string): boolean => (/^(https?:\/{2}|\.?\/)/i).test(str); +import { SVGLoader } from '@refinitiv-ui/utils/loader.js'; /** * Caches and provides icon SVGs, Loaded either by name from CDN or directly by URL. * Uses singleton pattern */ -class IconLoader extends CdnLoader { - private cdnPrefix = new Deferred(); - - private _isPrefixSet = false; - - /** - * @returns {boolean} clarify whether prefix has been set or not. - */ - public get isPrefixSet (): boolean { - return this._isPrefixSet; - } - - /** - * Sets clarify whether prefix has been set or not - * @param value - new value that is going to set. - */ - public set isPrefixSet (value: boolean) { - if (this._isPrefixSet !== value) { - this._isPrefixSet = value; - } - } - - /** - * @returns promise, which will be resolved with CDN prefix, once set. - */ - public getCdnPrefix (): Promise { - return this.cdnPrefix.promise; - } - - /** - * Sets CDN prefix to load source. - * Resolves deferred promise with CDN prefix and sets src used to check whether prefix is already set or not. - * @param prefix - CDN prefix. - * @returns {void} - */ - public setCdnPrefix (prefix: string): void { - if (prefix) { - this.cdnPrefix.resolve(prefix); - this.isPrefixSet = true; - } - } - - /** - * Creates complete source using CDN prefix and src. - * Waits for CDN prefix to be set. - * @param iconName - resource path for download - * @returns Promise, which will be resolved with complete source. - */ - public async getSrc (iconName: string): Promise { - if (isUrl(iconName)) { - return iconName; - } - return iconName ? `${await this.getCdnPrefix()}${iconName}.svg` : ''; - } - - /** - * Loads icon and returns the body of the SVG - * @param icon Icon name to load - * @returns SVG body of the response - */ - public async loadSVG (icon: string): Promise { - if (!icon) { - return; - } - icon = await this.getSrc(icon); - const response = await this.load(icon); - if (response && response.status === 200 && response.getResponseHeader('content-type') === 'image/svg+xml') { - const container = document.createElement('svg'); - container.innerHTML = response.responseText; - this.stripUnsafeNodes(...container.children); - const svgRoot = container.firstElementChild as SVGElement | null; - if (svgRoot) { - svgRoot.setAttribute('focusable', 'false'); /* disable IE11 focus on SVG root element */ - } - return container.innerHTML; - } - return ''; - } -} +class IconLoader extends SVGLoader {} const iconLoaderInstance = new IconLoader(); diff --git a/packages/utils/src/loader.ts b/packages/utils/src/loader.ts index 001a9c132f..6e8a47204a 100644 --- a/packages/utils/src/loader.ts +++ b/packages/utils/src/loader.ts @@ -1,2 +1,3 @@ -export { CdnLoader } from './loader/cdn-loader.js'; +export { CDNLoader } from './loader/cdn-loader.js'; +export { SVGLoader } from './loader/svg-loader.js'; export { Deferred } from './loader/deferred.js'; diff --git a/packages/utils/src/loader/cdn-loader.ts b/packages/utils/src/loader/cdn-loader.ts index 30a381f641..f18c2b2022 100644 --- a/packages/utils/src/loader/cdn-loader.ts +++ b/packages/utils/src/loader/cdn-loader.ts @@ -1,26 +1,46 @@ +import { Deferred } from './deferred.js'; + /** * Caches and provides any load results, Loaded either by name from CDN or directly by URL. */ -export class CdnLoader { +export class CDNLoader { + + private _isPrefixSet = false; + /** + * Internal response cache + */ private responseCache = new Map>(); /** - * Strips any unsafe nodes from the response. - * Prevents any external attacks from malicious scripts - * and other hijack methods. Only keeps SVGGraphicsElements. - * @param elements COllection of nodes + * CDN prefix to prepend to src + */ + private cdnPrefix = new Deferred(); + + /** + * @returns {boolean} clarify whether prefix has been set or not. + */ + public get isPrefixSet (): boolean { + return this._isPrefixSet; + } + + /** + * @returns promise, which will be resolved with CDN prefix, once set. + */ + public getCdnPrefix (): Promise { + return this.cdnPrefix.promise; + } + + /** + * Sets CDN prefix to load source. + * Resolves deferred promise with CDN prefix and sets src used to check whether prefix is already set or not. + * @param prefix - CDN prefix. * @returns {void} */ - protected stripUnsafeNodes (...elements: Node[]): void { - for (const el of elements) { - // Type of SVGGraphicsElement? - if (el instanceof SVGElement && 'getBBox' in el) { - this.stripUnsafeNodes(...(el as SVGElement).childNodes); - } - else { - el.parentNode?.removeChild(el); - } + public setCdnPrefix (prefix: string): void { + if (prefix) { + this.cdnPrefix.resolve(prefix); + this._isPrefixSet = true; } } diff --git a/packages/utils/src/loader/svg-loader.ts b/packages/utils/src/loader/svg-loader.ts new file mode 100644 index 0000000000..c8cfb54a1a --- /dev/null +++ b/packages/utils/src/loader/svg-loader.ts @@ -0,0 +1,105 @@ +import { CDNLoader } from './cdn-loader.js'; + +/** + * Checks a string to see if it's a valid URL + * @param str String to test + * @returns is URL + */ +const isUrl = (str: string): boolean => (/^(https?:\/{2}|\.?\/)/i).test(str); + +/** + * Strips any event attributes which could be used to + * maliciously hijack the application. + * @param element Element to check + * @returns {void} + */ +const stripUnsafeAttributes = (element: SVGElement): void => { + const attributes = element.getAttributeNames(); + for (const attribute of attributes) { + // Remove event attributes e.g., `onclick` + if (attribute.startsWith('on')) { + element.removeAttribute(attribute); + } + } +}; + +/** + * Strips any unsafe nodes from the response. + * Prevents any external attacks from malicious scripts + * and other hijack methods. Only keeps SVGGraphicsElements. + * @param elements COllection of nodes + * @returns {void} + */ +const stripUnsafeNodes = (...elements: Node[]): void => { + for (const el of elements) { + // Type of SVGGraphicsElement? + if (el instanceof SVGElement && 'getBBox' in el) { + stripUnsafeAttributes(el); + stripUnsafeNodes(...(el as SVGElement).childNodes); + } + else { + el.parentNode?.removeChild(el); + } + } +}; + +/** + * Checks to see whether the response is a valid SVG response + * @param response Request response to test + * @returns Is valid SVG + */ +const isValidResponse = (response: XMLHttpRequest | undefined): response is XMLHttpRequest => { + return !!response && response.status === 200 + && response.getResponseHeader('content-type') === 'image/svg+xml'; +}; + +/** + * Extracts and sanitizes any valid SVG from response. + * @param response Response to extract SVG from + * @returns SVG result or null + */ +const extractSafeSVG = (response: XMLHttpRequest | undefined): SVGElement | null => { + if (isValidResponse(response) && response.responseXML) { + const svgDocument = response.responseXML.cloneNode(true) as Document; + const svg = svgDocument.firstElementChild; + if (svg instanceof SVGElement) { + stripUnsafeNodes(svg); + return svg; + } + } + return null; +}; + +/** + * Caches and provides SVGs, loaded either by name from CDN or directly by URL. + * Uses singleton pattern + */ +export class SVGLoader extends CDNLoader { + + /** + * Creates complete source using CDN prefix and src. + * Waits for CDN prefix to be set. + * @param name - resource path for download + * @returns Promise, which will be resolved with complete source. + */ + public async getSrc (name: string): Promise { + if (isUrl(name)) { + return name; + } + return name ? `${await this.getCdnPrefix()}${name}.svg` : ''; + } + + /** + * Loads icon and returns the body of the SVG + * @param name Name of SVG to load + * @returns SVG body of the response + */ + public async loadSVG (name: string): Promise { + if (!name) { + return; + } + const src = await this.getSrc(name); + const response = await this.load(src); + return extractSafeSVG(response)?.outerHTML; + } +}