From b19519ece2df150a22998a141c772eb399d79682 Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Wed, 1 Apr 2020 20:51:00 +0300 Subject: [PATCH 01/20] feat(core): add dom strategy --- .../core/src/lib/strategies/dom.strategy.ts | 28 +++++++++++ .../packages/core/src/lib/strategies/index.ts | 1 + .../core/src/lib/tests/dom.strategy.spec.ts | 49 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 npm/ng-packs/packages/core/src/lib/strategies/dom.strategy.ts create mode 100644 npm/ng-packs/packages/core/src/lib/strategies/index.ts create mode 100644 npm/ng-packs/packages/core/src/lib/tests/dom.strategy.spec.ts diff --git a/npm/ng-packs/packages/core/src/lib/strategies/dom.strategy.ts b/npm/ng-packs/packages/core/src/lib/strategies/dom.strategy.ts new file mode 100644 index 0000000000..4fbe18d235 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/strategies/dom.strategy.ts @@ -0,0 +1,28 @@ +export class DomStrategy { + constructor( + public target: HTMLElement = document.head, + public position: InsertPosition = 'beforeend', + ) {} + + insertElement(element: T) { + this.target.insertAdjacentElement(this.position, element); + } +} + +export const DOM_STRATEGY = { + AfterElement(element: HTMLElement) { + return new DomStrategy(element, 'afterend'); + }, + AppendToBody() { + return new DomStrategy(document.body, 'beforeend'); + }, + AppendToHead() { + return new DomStrategy(document.head, 'beforeend'); + }, + BeforeElement(element: HTMLElement) { + return new DomStrategy(element, 'beforebegin'); + }, + PrependToHead() { + return new DomStrategy(document.head, 'afterbegin'); + }, +}; diff --git a/npm/ng-packs/packages/core/src/lib/strategies/index.ts b/npm/ng-packs/packages/core/src/lib/strategies/index.ts new file mode 100644 index 0000000000..2506a819de --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/strategies/index.ts @@ -0,0 +1 @@ +export * from './dom.strategy'; diff --git a/npm/ng-packs/packages/core/src/lib/tests/dom.strategy.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/dom.strategy.spec.ts new file mode 100644 index 0000000000..7e5f264f2b --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tests/dom.strategy.spec.ts @@ -0,0 +1,49 @@ +import { DomStrategy, DOM_STRATEGY } from '../strategies'; + +describe('DomStrategy', () => { + describe('#insertElement', () => { + it('should append element to head by default', () => { + const strategy = new DomStrategy(); + const element = document.createElement('script'); + strategy.insertElement(element); + + expect(document.head.lastChild).toBe(element); + }); + + it('should append element to body when body is given as target', () => { + const strategy = new DomStrategy(document.body); + const element = document.createElement('script'); + strategy.insertElement(element); + + expect(document.body.lastChild).toBe(element); + }); + + it('should prepend to head when position is given as "afterbegin"', () => { + const strategy = new DomStrategy(undefined, 'afterbegin'); + const element = document.createElement('script'); + strategy.insertElement(element); + + expect(document.head.firstChild).toBe(element); + }); + }); +}); + +describe('DOM_STRATEGY', () => { + let div = document.createElement('DIV'); + + beforeEach(() => { + document.body.innerHTML = ''; + document.body.appendChild(div); + }); + + test.each` + name | target | position + ${'AfterElement'} | ${div} | ${'afterend'} + ${'AppendToBody'} | ${document.body} | ${'beforeend'} + ${'AppendToHead'} | ${document.head} | ${'beforeend'} + ${'BeforeElement'} | ${div} | ${'beforebegin'} + ${'PrependToHead'} | ${document.head} | ${'afterbegin'} + `('should successfully map $name to CrossOriginStrategy', ({ name, target, position }) => { + expect(DOM_STRATEGY[name](target)).toEqual(new DomStrategy(target, position)); + }); +}); From cb51ca7fc14660db52281a6556bfcfdb70309f98 Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Wed, 1 Apr 2020 20:52:02 +0300 Subject: [PATCH 02/20] feat(core): add cross-origin strategy --- .../lib/strategies/cross-origin.strategy.ts | 17 +++++++++ .../packages/core/src/lib/strategies/index.ts | 1 + .../lib/tests/cross-origin.strategy.spec.ts | 38 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 npm/ng-packs/packages/core/src/lib/strategies/cross-origin.strategy.ts create mode 100644 npm/ng-packs/packages/core/src/lib/tests/cross-origin.strategy.spec.ts diff --git a/npm/ng-packs/packages/core/src/lib/strategies/cross-origin.strategy.ts b/npm/ng-packs/packages/core/src/lib/strategies/cross-origin.strategy.ts new file mode 100644 index 0000000000..c56d7a3d54 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/strategies/cross-origin.strategy.ts @@ -0,0 +1,17 @@ +export class CrossOriginStrategy { + constructor(public crossorigin: 'anonymous' | 'use-credentials', public integrity?: string) {} + + setCrossOrigin(element: T) { + if (this.integrity) element.setAttribute('integrity', this.integrity); + element.setAttribute('crossorigin', this.crossorigin); + } +} + +export const CROSS_ORIGIN_STRATEGY = { + Anonymous(integrity?: string) { + return new CrossOriginStrategy('anonymous', integrity); + }, + UseCredentials(integrity?: string) { + return new CrossOriginStrategy('use-credentials', integrity); + }, +}; diff --git a/npm/ng-packs/packages/core/src/lib/strategies/index.ts b/npm/ng-packs/packages/core/src/lib/strategies/index.ts index 2506a819de..d1a367e138 100644 --- a/npm/ng-packs/packages/core/src/lib/strategies/index.ts +++ b/npm/ng-packs/packages/core/src/lib/strategies/index.ts @@ -1 +1,2 @@ +export * from './cross-origin.strategy'; export * from './dom.strategy'; diff --git a/npm/ng-packs/packages/core/src/lib/tests/cross-origin.strategy.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/cross-origin.strategy.spec.ts new file mode 100644 index 0000000000..25cc6f0c93 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tests/cross-origin.strategy.spec.ts @@ -0,0 +1,38 @@ +import { CrossOriginStrategy, CROSS_ORIGIN_STRATEGY } from '../strategies'; +import { uuid } from '../utils'; + +describe('CrossOriginStrategy', () => { + describe('#setCrossOrigin', () => { + it('should set crossorigin attribute', () => { + const strategy = new CrossOriginStrategy('use-credentials'); + const element = document.createElement('link'); + strategy.setCrossOrigin(element); + + expect(element.crossOrigin).toBe('use-credentials'); + }); + + it('should set integrity attribute when given', () => { + const integrity = uuid(); + const strategy = new CrossOriginStrategy('anonymous', integrity); + const element = document.createElement('link'); + strategy.setCrossOrigin(element); + + expect(element.crossOrigin).toBe('anonymous'); + expect(element.getAttribute('integrity')).toBe(integrity); + }); + }); +}); + +describe('CROSS_ORIGIN_STRATEGY', () => { + test.each` + name | integrity | crossOrigin + ${'Anonymous'} | ${undefined} | ${'anonymous'} + ${'Anonymous'} | ${uuid()} | ${'anonymous'} + ${'UseCredentials'} | ${undefined} | ${'use-credentials'} + ${'UseCredentials'} | ${uuid()} | ${'use-credentials'} + `('should successfully map $name to CrossOriginStrategy', ({ name, integrity, crossOrigin }) => { + expect(CROSS_ORIGIN_STRATEGY[name](integrity)).toEqual( + new CrossOriginStrategy(crossOrigin, integrity), + ); + }); +}); From b55bc42779912c395856a7ea8d1d1725c9ee86ea Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Wed, 1 Apr 2020 20:53:30 +0300 Subject: [PATCH 03/20] feat(core): add fromLazyLoad utility function --- .../src/lib/tests/lazy-load-utils.spec.ts | 105 ++++++++++++++++++ .../packages/core/src/lib/utils/index.ts | 1 + .../core/src/lib/utils/lazy-load-utils.ts | 54 +++++++++ 3 files changed, 160 insertions(+) create mode 100644 npm/ng-packs/packages/core/src/lib/tests/lazy-load-utils.spec.ts create mode 100644 npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts diff --git a/npm/ng-packs/packages/core/src/lib/tests/lazy-load-utils.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/lazy-load-utils.spec.ts new file mode 100644 index 0000000000..657afeead1 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tests/lazy-load-utils.spec.ts @@ -0,0 +1,105 @@ +import { DomStrategy, DOM_STRATEGY } from '../strategies'; +import { CrossOriginStrategy, CROSS_ORIGIN_STRATEGY } from '../strategies/cross-origin.strategy'; +import { uuid } from '../utils'; +import { fromLazyLoad } from '../utils/lazy-load-utils'; + +describe('Lazy Load Utils', () => { + describe('#fromLazyLoad', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should append to head by default', () => { + const element = document.createElement('link'); + const spy = jest.spyOn(document.head, 'insertAdjacentElement'); + + fromLazyLoad(element); + expect(spy).toHaveBeenCalledWith('beforeend', element); + }); + + it('should allow setting a dom strategy', () => { + const element = document.createElement('link'); + const spy = jest.spyOn(document.head, 'insertAdjacentElement'); + + fromLazyLoad(element, DOM_STRATEGY.PrependToHead()); + expect(spy).toHaveBeenCalledWith('afterbegin', element); + }); + + it('should set crossorigin to "anonymous" by default', () => { + const element = document.createElement('link'); + + fromLazyLoad(element); + + expect(element.crossOrigin).toBe('anonymous'); + }); + + it('should allow setting a crossorigin strategy', () => { + const element = document.createElement('link'); + + const integrity = uuid(); + + fromLazyLoad(element, undefined, CROSS_ORIGIN_STRATEGY.UseCredentials(integrity)); + + expect(element.crossOrigin).toBe('use-credentials'); + expect(element.getAttribute('integrity')).toBe(integrity); + }); + + it('should emit error event on fail and clear callbacks', done => { + const error = new CustomEvent('error'); + const parentNode = { removeChild: jest.fn() }; + const element = ({ parentNode } as any) as HTMLLinkElement; + + fromLazyLoad( + element, + { + insertElement(el: HTMLLinkElement) { + expect(el).toBe(element); + + setTimeout(() => { + el.onerror(error); + }, 0); + }, + } as DomStrategy, + { + setCrossOrigin(el: HTMLLinkElement) {}, + } as CrossOriginStrategy, + ).subscribe({ + error: value => { + expect(value).toBe(error); + expect(parentNode.removeChild).toHaveBeenCalledWith(element); + expect(element.onerror).toBeNull(); + done(); + }, + }); + }); + + it('should emit load event on success and clear callbacks', done => { + const success = new CustomEvent('load'); + const parentNode = { removeChild: jest.fn() }; + const element = ({ parentNode } as any) as HTMLLinkElement; + + fromLazyLoad( + element, + { + insertElement(el: HTMLLinkElement) { + expect(el).toBe(element); + + setTimeout(() => { + el.onload(success); + }, 0); + }, + } as DomStrategy, + { + setCrossOrigin(el: HTMLLinkElement) {}, + } as CrossOriginStrategy, + ).subscribe({ + next: value => { + expect(value).toBe(success); + expect(parentNode.removeChild).not.toHaveBeenCalled(); + expect(element.onload).toBeNull(); + done(); + }, + }); + }); + }); +}); diff --git a/npm/ng-packs/packages/core/src/lib/utils/index.ts b/npm/ng-packs/packages/core/src/lib/utils/index.ts index 0043152ada..a0404f7568 100644 --- a/npm/ng-packs/packages/core/src/lib/utils/index.ts +++ b/npm/ng-packs/packages/core/src/lib/utils/index.ts @@ -1,5 +1,6 @@ export * from './common-utils'; export * from './generator-utils'; export * from './initial-utils'; +export * from './lazy-load-utils'; export * from './route-utils'; export * from './rxjs-utils'; diff --git a/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts b/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts new file mode 100644 index 0000000000..5aee3bc715 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts @@ -0,0 +1,54 @@ +import { Observable, Observer } from 'rxjs'; +import { + CrossOriginStrategy, + CROSS_ORIGIN_STRATEGY, + DomStrategy, + DOM_STRATEGY, +} from '../strategies'; + +export function fromLazyLoad( + element: HTMLScriptElement | HTMLLinkElement, + domStrategy: DomStrategy = DOM_STRATEGY.AppendToHead(), + crossOriginStrategy: CrossOriginStrategy = CROSS_ORIGIN_STRATEGY.Anonymous(), +): Observable { + crossOriginStrategy.setCrossOrigin(element); + domStrategy.insertElement(element); + + return Observable.create((observer: Observer) => { + element.onload = event => { + clearCallbacks(element); + observer.next(event); + observer.complete(); + }; + + const handleError = createErrorHandler(observer, element); + + element.onerror = handleError; + element.onabort = handleError; + element.onemptied = handleError; + element.onstalled = handleError; + element.onsuspend = handleError; + + return () => { + clearCallbacks(element); + observer.complete(); + }; + }); +} + +function createErrorHandler(observer: Observer, element: HTMLElement) { + return function(event: Event | string) { + clearCallbacks(element); + element.parentNode.removeChild(element); + observer.error(event); + }; +} + +function clearCallbacks(element: HTMLElement) { + element.onload = null; + element.onerror = null; + element.onabort = null; + element.onemptied = null; + element.onstalled = null; + element.onsuspend = null; +} From 79b080671843e18eb30163594877b5a27b4768bf Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Wed, 1 Apr 2020 20:53:59 +0300 Subject: [PATCH 04/20] feat(core): add loading strategies --- .../packages/core/src/lib/strategies/index.ts | 1 + .../src/lib/strategies/loading.strategy.ts | 68 ++++++++++++++ .../src/lib/tests/loading.strategy.spec.ts | 90 +++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts create mode 100644 npm/ng-packs/packages/core/src/lib/tests/loading.strategy.spec.ts diff --git a/npm/ng-packs/packages/core/src/lib/strategies/index.ts b/npm/ng-packs/packages/core/src/lib/strategies/index.ts index d1a367e138..915887f92a 100644 --- a/npm/ng-packs/packages/core/src/lib/strategies/index.ts +++ b/npm/ng-packs/packages/core/src/lib/strategies/index.ts @@ -1,2 +1,3 @@ export * from './cross-origin.strategy'; export * from './dom.strategy'; +export * from './loading.strategy'; diff --git a/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts b/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts new file mode 100644 index 0000000000..eae23a0c7f --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts @@ -0,0 +1,68 @@ +import { Observable, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { fromLazyLoad } from '../utils'; +import { CrossOriginStrategy, CROSS_ORIGIN_STRATEGY } from './cross-origin.strategy'; +import { DomStrategy, DOM_STRATEGY } from './dom.strategy'; + +export abstract class LoadingStrategy { + constructor( + public path: string, + protected domStrategy: DomStrategy = DOM_STRATEGY.AppendToHead(), + protected crossOriginStrategy: CrossOriginStrategy = CROSS_ORIGIN_STRATEGY.Anonymous(), + ) {} + + abstract createElement(): T; + + createStream(): Observable { + return of(null).pipe( + switchMap(() => + fromLazyLoad(this.createElement(), this.domStrategy, this.crossOriginStrategy), + ), + ); + } +} + +export class ScriptLoadingStrategy extends LoadingStrategy { + constructor(src: string, domStrategy?: DomStrategy, crossOriginStrategy?: CrossOriginStrategy) { + super(src, domStrategy, crossOriginStrategy); + } + + createElement(): HTMLScriptElement { + const element = document.createElement('script'); + element.src = this.path; + + return element; + } +} + +export class StyleLoadingStrategy extends LoadingStrategy { + constructor(href: string, domStrategy?: DomStrategy, crossOriginStrategy?: CrossOriginStrategy) { + super(href, domStrategy, crossOriginStrategy); + } + + createElement(): HTMLLinkElement { + const element = document.createElement('link'); + element.rel = 'stylesheet'; + element.href = this.path; + + return element; + } +} + +export const LOADING_STRATEGY = { + AppendAnonymousScriptToBody(src: string) { + return new ScriptLoadingStrategy(src, DOM_STRATEGY.AppendToBody()); + }, + AppendAnonymousScriptToHead(src: string) { + return new ScriptLoadingStrategy(src); + }, + AppendAnonymousStyleToHead(src: string) { + return new StyleLoadingStrategy(src); + }, + PrependAnonymousScriptToHead(src: string) { + return new ScriptLoadingStrategy(src, DOM_STRATEGY.PrependToHead()); + }, + PrependAnonymousStyleToHead(src: string) { + return new StyleLoadingStrategy(src, DOM_STRATEGY.PrependToHead()); + }, +}; diff --git a/npm/ng-packs/packages/core/src/lib/tests/loading.strategy.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/loading.strategy.spec.ts new file mode 100644 index 0000000000..9260cd8948 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tests/loading.strategy.spec.ts @@ -0,0 +1,90 @@ +import { + CROSS_ORIGIN_STRATEGY, + DOM_STRATEGY, + LOADING_STRATEGY, + ScriptLoadingStrategy, + StyleLoadingStrategy, +} from '../strategies'; + +const path = 'http://example.com/'; + +describe('ScriptLoadingStrategy', () => { + describe('#createElement', () => { + it('should return a script element with src attribute', () => { + const strategy = new ScriptLoadingStrategy(path); + const element = strategy.createElement(); + + expect(element.tagName).toBe('SCRIPT'); + expect(element.src).toBe(path); + }); + }); + + describe('#createStream', () => { + it('should use given dom and cross-origin strategies', done => { + const domStrategy = DOM_STRATEGY.PrependToHead(); + const crossOriginStrategy = CROSS_ORIGIN_STRATEGY.UseCredentials(); + + domStrategy.insertElement = jest.fn((el: HTMLScriptElement) => { + setTimeout(() => { + el.onload(new CustomEvent('success', { detail: el.crossOrigin })); + }, 0); + }) as any; + + const strategy = new ScriptLoadingStrategy(path, domStrategy, crossOriginStrategy); + + strategy.createStream().subscribe(event => { + expect(event.detail).toBe('use-credentials'); + done(); + }); + }); + }); +}); + +describe('StyleLoadingStrategy', () => { + describe('#createElement', () => { + it('should return a style element with href and rel attributes', () => { + const strategy = new StyleLoadingStrategy(path); + const element = strategy.createElement(); + + expect(element.tagName).toBe('LINK'); + expect(element.href).toBe(path); + expect(element.rel).toBe('stylesheet'); + }); + }); + + describe('#createStream', () => { + it('should use given dom and cross-origin strategies', done => { + const domStrategy = DOM_STRATEGY.PrependToHead(); + const crossOriginStrategy = CROSS_ORIGIN_STRATEGY.UseCredentials(); + + domStrategy.insertElement = jest.fn((el: HTMLLinkElement) => { + setTimeout(() => { + el.onload(new CustomEvent('success', { detail: el.crossOrigin })); + }, 0); + }) as any; + + const strategy = new StyleLoadingStrategy(path, domStrategy, crossOriginStrategy); + + strategy.createStream().subscribe(event => { + expect(event.detail).toBe('use-credentials'); + done(); + }); + }); + }); +}); + +describe('LOADING_STRATEGY', () => { + test.each` + name | Strategy | domStrategy + ${'AppendAnonymousScriptToBody'} | ${ScriptLoadingStrategy} | ${'AppendToBody'} + ${'AppendAnonymousScriptToHead'} | ${ScriptLoadingStrategy} | ${'AppendToHead'} + ${'AppendAnonymousStyleToHead'} | ${StyleLoadingStrategy} | ${'AppendToHead'} + ${'PrependAnonymousScriptToHead'} | ${ScriptLoadingStrategy} | ${'PrependToHead'} + ${'PrependAnonymousStyleToHead'} | ${StyleLoadingStrategy} | ${'PrependToHead'} + `( + 'should successfully map $name to $Strategy.name with $domStrategy dom strategy', + ({ name, Strategy, domStrategy }) => { + expect(LOADING_STRATEGY[name](path)).toEqual(new Strategy(path, DOM_STRATEGY[domStrategy]())); + }, + ); +}); From 4cc3a272ebe8a207d2cb4896195970c241feed9c Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Thu, 2 Apr 2020 13:20:08 +0300 Subject: [PATCH 05/20] feat(core): add content security strategy --- .../strategies/content-security.strategy.ts | 32 +++++++++++++ .../packages/core/src/lib/strategies/index.ts | 1 + .../src/lib/strategies/loading.strategy.ts | 27 ++++++++--- .../tests/content-security.strategy.spec.ts | 41 +++++++++++++++++ .../src/lib/tests/lazy-load-utils.spec.ts | 45 +++++++++++++++++-- .../src/lib/tests/loading.strategy.spec.ts | 43 +++++++++++++++--- .../core/src/lib/utils/lazy-load-utils.ts | 4 ++ 7 files changed, 178 insertions(+), 15 deletions(-) create mode 100644 npm/ng-packs/packages/core/src/lib/strategies/content-security.strategy.ts create mode 100644 npm/ng-packs/packages/core/src/lib/tests/content-security.strategy.spec.ts diff --git a/npm/ng-packs/packages/core/src/lib/strategies/content-security.strategy.ts b/npm/ng-packs/packages/core/src/lib/strategies/content-security.strategy.ts new file mode 100644 index 0000000000..54016b5836 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/strategies/content-security.strategy.ts @@ -0,0 +1,32 @@ +export abstract class ContentSecurityStrategy { + constructor(public nonce?: string) {} + + abstract applyCSP(element: HTMLScriptElement | HTMLStyleElement): void; +} + +export class StrictContentSecurityStrategy extends ContentSecurityStrategy { + constructor(nonce: string) { + super(nonce); + } + + applyCSP(element: HTMLScriptElement | HTMLStyleElement) { + element.setAttribute('nonce', this.nonce); + } +} + +export class LooseContentSecurityStrategy extends ContentSecurityStrategy { + constructor() { + super(); + } + + applyCSP(_: HTMLScriptElement | HTMLStyleElement) {} +} + +export const CONTENT_SECURITY_STRATEGY = { + Loose() { + return new LooseContentSecurityStrategy(); + }, + Strict(nonce: string) { + return new StrictContentSecurityStrategy(nonce); + }, +}; diff --git a/npm/ng-packs/packages/core/src/lib/strategies/index.ts b/npm/ng-packs/packages/core/src/lib/strategies/index.ts index 915887f92a..93904af549 100644 --- a/npm/ng-packs/packages/core/src/lib/strategies/index.ts +++ b/npm/ng-packs/packages/core/src/lib/strategies/index.ts @@ -1,3 +1,4 @@ +export * from './content-security.strategy'; export * from './cross-origin.strategy'; export * from './dom.strategy'; export * from './loading.strategy'; diff --git a/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts b/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts index eae23a0c7f..5c8bd485ca 100644 --- a/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts +++ b/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts @@ -1,6 +1,7 @@ import { Observable, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { fromLazyLoad } from '../utils'; +import { ContentSecurityStrategy, CONTENT_SECURITY_STRATEGY } from './content-security.strategy'; import { CrossOriginStrategy, CROSS_ORIGIN_STRATEGY } from './cross-origin.strategy'; import { DomStrategy, DOM_STRATEGY } from './dom.strategy'; @@ -9,6 +10,7 @@ export abstract class LoadingStrategy(): Observable { return of(null).pipe( switchMap(() => - fromLazyLoad(this.createElement(), this.domStrategy, this.crossOriginStrategy), + fromLazyLoad( + this.createElement(), + this.domStrategy, + this.crossOriginStrategy, + this.contentSecurityStrategy, + ), ), ); } } export class ScriptLoadingStrategy extends LoadingStrategy { - constructor(src: string, domStrategy?: DomStrategy, crossOriginStrategy?: CrossOriginStrategy) { - super(src, domStrategy, crossOriginStrategy); + constructor( + src: string, + domStrategy?: DomStrategy, + crossOriginStrategy?: CrossOriginStrategy, + contentSecurityStrategy?: ContentSecurityStrategy, + ) { + super(src, domStrategy, crossOriginStrategy, contentSecurityStrategy); } createElement(): HTMLScriptElement { @@ -36,8 +48,13 @@ export class ScriptLoadingStrategy extends LoadingStrategy { } export class StyleLoadingStrategy extends LoadingStrategy { - constructor(href: string, domStrategy?: DomStrategy, crossOriginStrategy?: CrossOriginStrategy) { - super(href, domStrategy, crossOriginStrategy); + constructor( + href: string, + domStrategy?: DomStrategy, + crossOriginStrategy?: CrossOriginStrategy, + contentSecurityStrategy?: ContentSecurityStrategy, + ) { + super(href, domStrategy, crossOriginStrategy, contentSecurityStrategy); } createElement(): HTMLLinkElement { diff --git a/npm/ng-packs/packages/core/src/lib/tests/content-security.strategy.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/content-security.strategy.spec.ts new file mode 100644 index 0000000000..06db799ca0 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tests/content-security.strategy.spec.ts @@ -0,0 +1,41 @@ +import { + CONTENT_SECURITY_STRATEGY, + LooseContentSecurityStrategy, + StrictContentSecurityStrategy, +} from '../strategies'; +import { uuid } from '../utils'; + +describe('LooseContentSecurityStrategy', () => { + describe('#applyCSP', () => { + it('should not set nonce attribute', () => { + const strategy = new LooseContentSecurityStrategy(); + const element = document.createElement('link'); + strategy.applyCSP(element); + + expect(element.getAttribute('nonce')).toBeNull(); + }); + }); +}); + +describe('StrictContentSecurityStrategy', () => { + describe('#applyCSP', () => { + it('should set nonce attribute', () => { + const nonce = uuid(); + const strategy = new StrictContentSecurityStrategy(nonce); + const element = document.createElement('link'); + strategy.applyCSP(element); + + expect(element.getAttribute('nonce')).toBe(nonce); + }); + }); +}); + +describe('CONTENT_SECURITY_STRATEGY', () => { + test.each` + name | Strategy | nonce + ${'Loose'} | ${LooseContentSecurityStrategy} | ${undefined} + ${'Strict'} | ${StrictContentSecurityStrategy} | ${uuid()} + `('should successfully map $name to $Strategy.name', ({ name, Strategy, nonce }) => { + expect(CONTENT_SECURITY_STRATEGY[name](nonce)).toEqual(new Strategy(nonce)); + }); +}); diff --git a/npm/ng-packs/packages/core/src/lib/tests/lazy-load-utils.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/lazy-load-utils.spec.ts index 657afeead1..9f1212b78c 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/lazy-load-utils.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/lazy-load-utils.spec.ts @@ -1,4 +1,9 @@ -import { DomStrategy, DOM_STRATEGY } from '../strategies'; +import { + ContentSecurityStrategy, + CONTENT_SECURITY_STRATEGY, + DomStrategy, + DOM_STRATEGY, +} from '../strategies'; import { CrossOriginStrategy, CROSS_ORIGIN_STRATEGY } from '../strategies/cross-origin.strategy'; import { uuid } from '../utils'; import { fromLazyLoad } from '../utils/lazy-load-utils'; @@ -33,7 +38,15 @@ describe('Lazy Load Utils', () => { expect(element.crossOrigin).toBe('anonymous'); }); - it('should allow setting a crossorigin strategy', () => { + it('should not set integrity by default', () => { + const element = document.createElement('link'); + + fromLazyLoad(element); + + expect(element.getAttribute('integrity')).toBeNull(); + }); + + it('should allow setting a cross-origin strategy', () => { const element = document.createElement('link'); const integrity = uuid(); @@ -44,6 +57,24 @@ describe('Lazy Load Utils', () => { expect(element.getAttribute('integrity')).toBe(integrity); }); + it('should not set nonce by default', () => { + const element = document.createElement('link'); + + fromLazyLoad(element); + + expect(element.getAttribute('nonce')).toBeNull(); + }); + + it('should allow setting a content security strategy', () => { + const element = document.createElement('link'); + + const nonce = uuid(); + + fromLazyLoad(element, undefined, undefined, CONTENT_SECURITY_STRATEGY.Strict(nonce)); + + expect(element.getAttribute('nonce')).toBe(nonce); + }); + it('should emit error event on fail and clear callbacks', done => { const error = new CustomEvent('error'); const parentNode = { removeChild: jest.fn() }; @@ -61,8 +92,11 @@ describe('Lazy Load Utils', () => { }, } as DomStrategy, { - setCrossOrigin(el: HTMLLinkElement) {}, + setCrossOrigin(_: HTMLLinkElement) {}, } as CrossOriginStrategy, + { + applyCSP(_: HTMLLinkElement) {}, + } as ContentSecurityStrategy, ).subscribe({ error: value => { expect(value).toBe(error); @@ -90,8 +124,11 @@ describe('Lazy Load Utils', () => { }, } as DomStrategy, { - setCrossOrigin(el: HTMLLinkElement) {}, + setCrossOrigin(_: HTMLLinkElement) {}, } as CrossOriginStrategy, + { + applyCSP(_: HTMLLinkElement) {}, + } as ContentSecurityStrategy, ).subscribe({ next: value => { expect(value).toBe(success); diff --git a/npm/ng-packs/packages/core/src/lib/tests/loading.strategy.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/loading.strategy.spec.ts index 9260cd8948..745b858ff3 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/loading.strategy.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/loading.strategy.spec.ts @@ -1,12 +1,15 @@ import { + CONTENT_SECURITY_STRATEGY, CROSS_ORIGIN_STRATEGY, DOM_STRATEGY, LOADING_STRATEGY, ScriptLoadingStrategy, StyleLoadingStrategy, } from '../strategies'; +import { uuid } from '../utils'; const path = 'http://example.com/'; +const nonce = uuid(); describe('ScriptLoadingStrategy', () => { describe('#createElement', () => { @@ -23,17 +26,31 @@ describe('ScriptLoadingStrategy', () => { it('should use given dom and cross-origin strategies', done => { const domStrategy = DOM_STRATEGY.PrependToHead(); const crossOriginStrategy = CROSS_ORIGIN_STRATEGY.UseCredentials(); + const contentSecurityStrategy = CONTENT_SECURITY_STRATEGY.Strict(nonce); domStrategy.insertElement = jest.fn((el: HTMLScriptElement) => { setTimeout(() => { - el.onload(new CustomEvent('success', { detail: el.crossOrigin })); + el.onload( + new CustomEvent('success', { + detail: { + crossOrigin: el.crossOrigin, + nonce: el.getAttribute('nonce'), + }, + }), + ); }, 0); }) as any; - const strategy = new ScriptLoadingStrategy(path, domStrategy, crossOriginStrategy); + const strategy = new ScriptLoadingStrategy( + path, + domStrategy, + crossOriginStrategy, + contentSecurityStrategy, + ); strategy.createStream().subscribe(event => { - expect(event.detail).toBe('use-credentials'); + expect(event.detail.crossOrigin).toBe('use-credentials'); + expect(event.detail.nonce).toBe(nonce); done(); }); }); @@ -56,17 +73,31 @@ describe('StyleLoadingStrategy', () => { it('should use given dom and cross-origin strategies', done => { const domStrategy = DOM_STRATEGY.PrependToHead(); const crossOriginStrategy = CROSS_ORIGIN_STRATEGY.UseCredentials(); + const contentSecurityStrategy = CONTENT_SECURITY_STRATEGY.Strict(nonce); domStrategy.insertElement = jest.fn((el: HTMLLinkElement) => { setTimeout(() => { - el.onload(new CustomEvent('success', { detail: el.crossOrigin })); + el.onload( + new CustomEvent('success', { + detail: { + crossOrigin: el.crossOrigin, + nonce: el.getAttribute('nonce'), + }, + }), + ); }, 0); }) as any; - const strategy = new StyleLoadingStrategy(path, domStrategy, crossOriginStrategy); + const strategy = new StyleLoadingStrategy( + path, + domStrategy, + crossOriginStrategy, + contentSecurityStrategy, + ); strategy.createStream().subscribe(event => { - expect(event.detail).toBe('use-credentials'); + expect(event.detail.crossOrigin).toBe('use-credentials'); + expect(event.detail.nonce).toBe(nonce); done(); }); }); diff --git a/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts b/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts index 5aee3bc715..598db332f8 100644 --- a/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts +++ b/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts @@ -1,5 +1,7 @@ import { Observable, Observer } from 'rxjs'; import { + ContentSecurityStrategy, + CONTENT_SECURITY_STRATEGY, CrossOriginStrategy, CROSS_ORIGIN_STRATEGY, DomStrategy, @@ -10,8 +12,10 @@ export function fromLazyLoad( element: HTMLScriptElement | HTMLLinkElement, domStrategy: DomStrategy = DOM_STRATEGY.AppendToHead(), crossOriginStrategy: CrossOriginStrategy = CROSS_ORIGIN_STRATEGY.Anonymous(), + contentSecurityStrategy: ContentSecurityStrategy = CONTENT_SECURITY_STRATEGY.Loose(), ): Observable { crossOriginStrategy.setCrossOrigin(element); + contentSecurityStrategy.applyCSP(element); domStrategy.insertElement(element); return Observable.create((observer: Observer) => { From 221c78fa0df2d14a8b7714fcdaa6e3d4018c3913 Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Thu, 2 Apr 2020 14:13:00 +0300 Subject: [PATCH 06/20] feat(core): add new lazy load service --- .../src/lib/services/lazy-load.service.ts | 42 ++++++++++- .../src/lib/tests/lazy-load.service.spec.ts | 74 +++++++++++++++++-- 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/npm/ng-packs/packages/core/src/lib/services/lazy-load.service.ts b/npm/ng-packs/packages/core/src/lib/services/lazy-load.service.ts index f047db97be..f9c3cbeb55 100644 --- a/npm/ng-packs/packages/core/src/lib/services/lazy-load.service.ts +++ b/npm/ng-packs/packages/core/src/lib/services/lazy-load.service.ts @@ -1,20 +1,56 @@ import { Injectable } from '@angular/core'; -import { Observable, ReplaySubject, throwError } from 'rxjs'; +import { concat, Observable, of, ReplaySubject, throwError } from 'rxjs'; +import { delay, retryWhen, shareReplay, take, tap } from 'rxjs/operators'; +import { LoadingStrategy } from '../strategies'; import { uuid } from '../utils'; @Injectable({ providedIn: 'root', }) export class LazyLoadService { + readonly loaded = new Set(); + loadedLibraries: { [url: string]: ReplaySubject } = {}; + load(strategy: LoadingStrategy, retryTimes?: number, retryDelay?: number): Observable; load( urlOrUrls: string | string[], type: 'script' | 'style', - content: string = '', + content?: string, + targetQuery?: string, + position?: InsertPosition, + ): Observable; + load( + strategyOrUrl: LoadingStrategy | string | string[], + retryTimesOrType?: number | 'script' | 'style', + retryDelayOrContent?: number | string, targetQuery: string = 'body', position: InsertPosition = 'beforeend', - ): Observable { + ): Observable { + if (strategyOrUrl instanceof LoadingStrategy) { + const strategy = strategyOrUrl; + const retryTimes = retryTimesOrType as number; + const retryDelay = retryDelayOrContent as number; + + if (this.loaded.has(strategy.path)) return of(new CustomEvent('load')); + + return strategy.createStream().pipe( + retryWhen(error$ => + concat( + error$.pipe(delay(retryDelay), take(retryTimes)), + throwError(new CustomEvent('error')), + ), + ), + tap(() => this.loaded.add(strategy.path)), + delay(100), + shareReplay({ bufferSize: 1, refCount: true }), + ); + } + + let urlOrUrls = strategyOrUrl; + const content = retryDelayOrContent as string; + const type = retryTimesOrType as 'script' | 'style'; + if (!urlOrUrls && !content) { return throwError('Should pass url or content'); } else if (!urlOrUrls && content) { diff --git a/npm/ng-packs/packages/core/src/lib/tests/lazy-load.service.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/lazy-load.service.spec.ts index a29b60974e..3596da2591 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/lazy-load.service.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/lazy-load.service.spec.ts @@ -1,9 +1,65 @@ import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; +import { of, throwError } from 'rxjs'; +import { catchError, switchMap } from 'rxjs/operators'; import { LazyLoadService } from '../services/lazy-load.service'; -import { catchError } from 'rxjs/operators'; -import { of } from 'rxjs'; +import { ScriptLoadingStrategy } from '../strategies'; describe('LazyLoadService', () => { + describe('#load', () => { + const service = new LazyLoadService(); + const strategy = new ScriptLoadingStrategy('http://example.com/'); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should emit an error event if not loaded', done => { + const counter = jest.fn(); + jest.spyOn(strategy, 'createStream').mockReturnValueOnce( + of(null).pipe( + switchMap(() => { + counter(); + return throwError('THIS WILL NOT BE THE FINAL ERROR'); + }), + ), + ); + + service.load(strategy, 5, 0).subscribe({ + error: errorEvent => { + expect(errorEvent).toEqual(new CustomEvent('error')); + expect(counter).toHaveBeenCalledTimes(6); + expect(service.loaded.has(strategy.path)).toBe(false); + done(); + }, + }); + }); + + it('should emit a load event if loaded', done => { + const loadEvent = new CustomEvent('load'); + jest.spyOn(strategy, 'createStream').mockReturnValue(of(loadEvent)); + + service.load(strategy).subscribe({ + next: event => { + expect(event).toBe(loadEvent); + expect(service.loaded.has(strategy.path)).toBe(true); + done(); + }, + }); + }); + + it('should emit a custom load event if loaded if resource is loaded before', done => { + const loadEvent = new CustomEvent('load'); + service.loaded.add(strategy.path); + + service.load(strategy).subscribe(event => { + expect(event).toEqual(loadEvent); + done(); + }); + }); + }); +}); + +describe('LazyLoadService (Deprecated)', () => { let spectator: SpectatorService; let service: LazyLoadService; const scriptElement = document.createElement('script'); @@ -25,15 +81,17 @@ describe('LazyLoadService', () => { spy.mockReturnValue(scriptElement); service.load('https://abp.io', 'script', 'test').subscribe(res => { - expect(document.querySelector('script[src="https://abp.io"][type="text/javascript"]').textContent).toMatch( - 'test', - ); + expect( + document.querySelector('script[src="https://abp.io"][type="text/javascript"]').textContent, + ).toMatch('test'); }); scriptElement.onload(null); service.load('https://abp.io', 'script', 'test').subscribe(res => { - expect(document.querySelectorAll('script[src="https://abp.io"][type="text/javascript"]')).toHaveLength(1); + expect( + document.querySelectorAll('script[src="https://abp.io"][type="text/javascript"]'), + ).toHaveLength(1); done(); }); }); @@ -59,7 +117,9 @@ describe('LazyLoadService', () => { test('should load an link element', done => { service.load('https://abp.io', 'style').subscribe(res => { - expect(document.querySelector('link[type="text/css"][rel="stylesheet"][href="https://abp.io"]')).toBeTruthy(); + expect( + document.querySelector('link[type="text/css"][rel="stylesheet"][href="https://abp.io"]'), + ).toBeTruthy(); done(); }); From 4acc763fdcc50d276b39bfe1e53d0591ac177255 Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Thu, 2 Apr 2020 14:18:58 +0300 Subject: [PATCH 07/20] refactor(core): import directly from strategy files --- .../packages/core/src/lib/utils/lazy-load-utils.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts b/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts index 598db332f8..b4c4c3f1ce 100644 --- a/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts +++ b/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts @@ -2,11 +2,9 @@ import { Observable, Observer } from 'rxjs'; import { ContentSecurityStrategy, CONTENT_SECURITY_STRATEGY, - CrossOriginStrategy, - CROSS_ORIGIN_STRATEGY, - DomStrategy, - DOM_STRATEGY, -} from '../strategies'; +} from '../strategies/content-security.strategy'; +import { CrossOriginStrategy, CROSS_ORIGIN_STRATEGY } from '../strategies/cross-origin.strategy'; +import { DomStrategy, DOM_STRATEGY } from '../strategies/dom.strategy'; export function fromLazyLoad( element: HTMLScriptElement | HTMLLinkElement, From 6ddca15a5f3d1474d2802231b547997fba34c6eb Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Thu, 2 Apr 2020 14:28:14 +0300 Subject: [PATCH 08/20] feat(core): make strategies publicly available --- npm/ng-packs/packages/core/src/public-api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/npm/ng-packs/packages/core/src/public-api.ts b/npm/ng-packs/packages/core/src/public-api.ts index 3513427c2d..901536c85d 100644 --- a/npm/ng-packs/packages/core/src/public-api.ts +++ b/npm/ng-packs/packages/core/src/public-api.ts @@ -7,6 +7,7 @@ export * from './lib/abstracts'; export * from './lib/actions'; export * from './lib/components'; export * from './lib/constants'; +export * from './lib/core.module'; export * from './lib/directives'; export * from './lib/enums'; export * from './lib/guards'; @@ -16,7 +17,6 @@ export * from './lib/pipes'; export * from './lib/plugins'; export * from './lib/services'; export * from './lib/states'; +export * from './lib/strategies'; export * from './lib/tokens'; export * from './lib/utils'; - -export * from './lib/core.module'; From 181b732b24ada30fe21bd03db26e68a7a54ec597 Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Thu, 2 Apr 2020 14:45:44 +0300 Subject: [PATCH 09/20] fix(core): resolve lint issues --- .../packages/core/src/lib/strategies/loading.strategy.ts | 4 ++-- .../packages/core/src/lib/tests/dom.strategy.spec.ts | 2 +- npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts b/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts index 5c8bd485ca..113ab9a853 100644 --- a/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts +++ b/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts @@ -15,10 +15,10 @@ export abstract class LoadingStrategy(): Observable { + createStream(): Observable { return of(null).pipe( switchMap(() => - fromLazyLoad( + fromLazyLoad( this.createElement(), this.domStrategy, this.crossOriginStrategy, diff --git a/npm/ng-packs/packages/core/src/lib/tests/dom.strategy.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/dom.strategy.spec.ts index 7e5f264f2b..e82eac52ad 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/dom.strategy.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/dom.strategy.spec.ts @@ -29,7 +29,7 @@ describe('DomStrategy', () => { }); describe('DOM_STRATEGY', () => { - let div = document.createElement('DIV'); + const div = document.createElement('DIV'); beforeEach(() => { document.body.innerHTML = ''; diff --git a/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts b/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts index b4c4c3f1ce..09b4ec4e4f 100644 --- a/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts +++ b/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts @@ -16,8 +16,8 @@ export function fromLazyLoad( contentSecurityStrategy.applyCSP(element); domStrategy.insertElement(element); - return Observable.create((observer: Observer) => { - element.onload = event => { + return new Observable((observer: Observer) => { + element.onload = (event: T) => { clearCallbacks(element); observer.next(event); observer.complete(); @@ -39,6 +39,7 @@ export function fromLazyLoad( } function createErrorHandler(observer: Observer, element: HTMLElement) { + /* tslint:disable-next-line:only-arrow-functions */ return function(event: Event | string) { clearCallbacks(element); element.parentNode.removeChild(element); From 54428be03f9a7958b6c77ec991c7f2c0922eacfe Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Thu, 2 Apr 2020 15:33:55 +0300 Subject: [PATCH 10/20] fix(core): add default values to load method --- .../packages/core/src/lib/services/lazy-load.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/npm/ng-packs/packages/core/src/lib/services/lazy-load.service.ts b/npm/ng-packs/packages/core/src/lib/services/lazy-load.service.ts index f9c3cbeb55..b7400c3193 100644 --- a/npm/ng-packs/packages/core/src/lib/services/lazy-load.service.ts +++ b/npm/ng-packs/packages/core/src/lib/services/lazy-load.service.ts @@ -29,8 +29,8 @@ export class LazyLoadService { ): Observable { if (strategyOrUrl instanceof LoadingStrategy) { const strategy = strategyOrUrl; - const retryTimes = retryTimesOrType as number; - const retryDelay = retryDelayOrContent as number; + const retryTimes = (retryTimesOrType as number) || 2; + const retryDelay = (retryDelayOrContent as number) || 1000; if (this.loaded.has(strategy.path)) return of(new CustomEvent('load')); @@ -48,7 +48,7 @@ export class LazyLoadService { } let urlOrUrls = strategyOrUrl; - const content = retryDelayOrContent as string; + const content = (retryDelayOrContent as string) || ''; const type = retryTimesOrType as 'script' | 'style'; if (!urlOrUrls && !content) { From ae6475eb4b819c4955424ada3edae1aaf84abdaa Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Thu, 2 Apr 2020 15:43:54 +0300 Subject: [PATCH 11/20] feat(core): enable 0 retries and 0 delay retries --- .../packages/core/src/lib/services/lazy-load.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/npm/ng-packs/packages/core/src/lib/services/lazy-load.service.ts b/npm/ng-packs/packages/core/src/lib/services/lazy-load.service.ts index b7400c3193..5e605d8c5d 100644 --- a/npm/ng-packs/packages/core/src/lib/services/lazy-load.service.ts +++ b/npm/ng-packs/packages/core/src/lib/services/lazy-load.service.ts @@ -29,8 +29,8 @@ export class LazyLoadService { ): Observable { if (strategyOrUrl instanceof LoadingStrategy) { const strategy = strategyOrUrl; - const retryTimes = (retryTimesOrType as number) || 2; - const retryDelay = (retryDelayOrContent as number) || 1000; + const retryTimes = typeof retryTimesOrType === 'number' ? retryTimesOrType : 2; + const retryDelay = typeof retryDelayOrContent === 'number' ? retryDelayOrContent : 1000; if (this.loaded.has(strategy.path)) return of(new CustomEvent('load')); From 21095025a97f6db9d8344a774d322ab600c3c1c7 Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Thu, 2 Apr 2020 16:11:57 +0300 Subject: [PATCH 12/20] feat(core): add optional integrity in loading strategies --- .../src/lib/strategies/loading.strategy.ts | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts b/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts index 113ab9a853..2882c3f0a6 100644 --- a/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts +++ b/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts @@ -67,19 +67,39 @@ export class StyleLoadingStrategy extends LoadingStrategy { } export const LOADING_STRATEGY = { - AppendAnonymousScriptToBody(src: string) { - return new ScriptLoadingStrategy(src, DOM_STRATEGY.AppendToBody()); + AppendAnonymousScriptToBody(src: string, integrity?: string) { + return new ScriptLoadingStrategy( + src, + DOM_STRATEGY.AppendToBody(), + CROSS_ORIGIN_STRATEGY.Anonymous(integrity), + ); }, - AppendAnonymousScriptToHead(src: string) { - return new ScriptLoadingStrategy(src); + AppendAnonymousScriptToHead(src: string, integrity?: string) { + return new ScriptLoadingStrategy( + src, + DOM_STRATEGY.AppendToHead(), + CROSS_ORIGIN_STRATEGY.Anonymous(integrity), + ); }, - AppendAnonymousStyleToHead(src: string) { - return new StyleLoadingStrategy(src); + AppendAnonymousStyleToHead(src: string, integrity?: string) { + return new StyleLoadingStrategy( + src, + DOM_STRATEGY.AppendToHead(), + CROSS_ORIGIN_STRATEGY.Anonymous(integrity), + ); }, - PrependAnonymousScriptToHead(src: string) { - return new ScriptLoadingStrategy(src, DOM_STRATEGY.PrependToHead()); + PrependAnonymousScriptToHead(src: string, integrity?: string) { + return new ScriptLoadingStrategy( + src, + DOM_STRATEGY.PrependToHead(), + CROSS_ORIGIN_STRATEGY.Anonymous(integrity), + ); }, - PrependAnonymousStyleToHead(src: string) { - return new StyleLoadingStrategy(src, DOM_STRATEGY.PrependToHead()); + PrependAnonymousStyleToHead(src: string, integrity?: string) { + return new StyleLoadingStrategy( + src, + DOM_STRATEGY.PrependToHead(), + CROSS_ORIGIN_STRATEGY.Anonymous(integrity), + ); }, }; From 7da8011eeeeed041bdef9c25e591fefe3ff759cd Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Thu, 2 Apr 2020 19:59:20 +0300 Subject: [PATCH 13/20] refactor(core): separate lazy load & content insertion --- .../src/lib/strategies/loading.strategy.ts | 27 +++------------- .../src/lib/tests/lazy-load-utils.spec.ts | 31 +------------------ .../src/lib/tests/loading.strategy.spec.ts | 23 ++------------ .../core/src/lib/utils/lazy-load-utils.ts | 6 ---- 4 files changed, 8 insertions(+), 79 deletions(-) diff --git a/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts b/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts index 2882c3f0a6..be89a751ed 100644 --- a/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts +++ b/npm/ng-packs/packages/core/src/lib/strategies/loading.strategy.ts @@ -1,7 +1,6 @@ import { Observable, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { fromLazyLoad } from '../utils'; -import { ContentSecurityStrategy, CONTENT_SECURITY_STRATEGY } from './content-security.strategy'; import { CrossOriginStrategy, CROSS_ORIGIN_STRATEGY } from './cross-origin.strategy'; import { DomStrategy, DOM_STRATEGY } from './dom.strategy'; @@ -10,7 +9,6 @@ export abstract class LoadingStrategy(): Observable { return of(null).pipe( switchMap(() => - fromLazyLoad( - this.createElement(), - this.domStrategy, - this.crossOriginStrategy, - this.contentSecurityStrategy, - ), + fromLazyLoad(this.createElement(), this.domStrategy, this.crossOriginStrategy), ), ); } } export class ScriptLoadingStrategy extends LoadingStrategy { - constructor( - src: string, - domStrategy?: DomStrategy, - crossOriginStrategy?: CrossOriginStrategy, - contentSecurityStrategy?: ContentSecurityStrategy, - ) { - super(src, domStrategy, crossOriginStrategy, contentSecurityStrategy); + constructor(src: string, domStrategy?: DomStrategy, crossOriginStrategy?: CrossOriginStrategy) { + super(src, domStrategy, crossOriginStrategy); } createElement(): HTMLScriptElement { @@ -48,13 +36,8 @@ export class ScriptLoadingStrategy extends LoadingStrategy { } export class StyleLoadingStrategy extends LoadingStrategy { - constructor( - href: string, - domStrategy?: DomStrategy, - crossOriginStrategy?: CrossOriginStrategy, - contentSecurityStrategy?: ContentSecurityStrategy, - ) { - super(href, domStrategy, crossOriginStrategy, contentSecurityStrategy); + constructor(href: string, domStrategy?: DomStrategy, crossOriginStrategy?: CrossOriginStrategy) { + super(href, domStrategy, crossOriginStrategy); } createElement(): HTMLLinkElement { diff --git a/npm/ng-packs/packages/core/src/lib/tests/lazy-load-utils.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/lazy-load-utils.spec.ts index 9f1212b78c..7afb343e80 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/lazy-load-utils.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/lazy-load-utils.spec.ts @@ -1,9 +1,4 @@ -import { - ContentSecurityStrategy, - CONTENT_SECURITY_STRATEGY, - DomStrategy, - DOM_STRATEGY, -} from '../strategies'; +import { DomStrategy, DOM_STRATEGY } from '../strategies'; import { CrossOriginStrategy, CROSS_ORIGIN_STRATEGY } from '../strategies/cross-origin.strategy'; import { uuid } from '../utils'; import { fromLazyLoad } from '../utils/lazy-load-utils'; @@ -57,24 +52,6 @@ describe('Lazy Load Utils', () => { expect(element.getAttribute('integrity')).toBe(integrity); }); - it('should not set nonce by default', () => { - const element = document.createElement('link'); - - fromLazyLoad(element); - - expect(element.getAttribute('nonce')).toBeNull(); - }); - - it('should allow setting a content security strategy', () => { - const element = document.createElement('link'); - - const nonce = uuid(); - - fromLazyLoad(element, undefined, undefined, CONTENT_SECURITY_STRATEGY.Strict(nonce)); - - expect(element.getAttribute('nonce')).toBe(nonce); - }); - it('should emit error event on fail and clear callbacks', done => { const error = new CustomEvent('error'); const parentNode = { removeChild: jest.fn() }; @@ -94,9 +71,6 @@ describe('Lazy Load Utils', () => { { setCrossOrigin(_: HTMLLinkElement) {}, } as CrossOriginStrategy, - { - applyCSP(_: HTMLLinkElement) {}, - } as ContentSecurityStrategy, ).subscribe({ error: value => { expect(value).toBe(error); @@ -126,9 +100,6 @@ describe('Lazy Load Utils', () => { { setCrossOrigin(_: HTMLLinkElement) {}, } as CrossOriginStrategy, - { - applyCSP(_: HTMLLinkElement) {}, - } as ContentSecurityStrategy, ).subscribe({ next: value => { expect(value).toBe(success); diff --git a/npm/ng-packs/packages/core/src/lib/tests/loading.strategy.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/loading.strategy.spec.ts index 745b858ff3..4b950cf533 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/loading.strategy.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/loading.strategy.spec.ts @@ -1,15 +1,12 @@ import { - CONTENT_SECURITY_STRATEGY, CROSS_ORIGIN_STRATEGY, DOM_STRATEGY, LOADING_STRATEGY, ScriptLoadingStrategy, StyleLoadingStrategy, } from '../strategies'; -import { uuid } from '../utils'; const path = 'http://example.com/'; -const nonce = uuid(); describe('ScriptLoadingStrategy', () => { describe('#createElement', () => { @@ -26,7 +23,6 @@ describe('ScriptLoadingStrategy', () => { it('should use given dom and cross-origin strategies', done => { const domStrategy = DOM_STRATEGY.PrependToHead(); const crossOriginStrategy = CROSS_ORIGIN_STRATEGY.UseCredentials(); - const contentSecurityStrategy = CONTENT_SECURITY_STRATEGY.Strict(nonce); domStrategy.insertElement = jest.fn((el: HTMLScriptElement) => { setTimeout(() => { @@ -34,23 +30,16 @@ describe('ScriptLoadingStrategy', () => { new CustomEvent('success', { detail: { crossOrigin: el.crossOrigin, - nonce: el.getAttribute('nonce'), }, }), ); }, 0); }) as any; - const strategy = new ScriptLoadingStrategy( - path, - domStrategy, - crossOriginStrategy, - contentSecurityStrategy, - ); + const strategy = new ScriptLoadingStrategy(path, domStrategy, crossOriginStrategy); strategy.createStream().subscribe(event => { expect(event.detail.crossOrigin).toBe('use-credentials'); - expect(event.detail.nonce).toBe(nonce); done(); }); }); @@ -73,7 +62,6 @@ describe('StyleLoadingStrategy', () => { it('should use given dom and cross-origin strategies', done => { const domStrategy = DOM_STRATEGY.PrependToHead(); const crossOriginStrategy = CROSS_ORIGIN_STRATEGY.UseCredentials(); - const contentSecurityStrategy = CONTENT_SECURITY_STRATEGY.Strict(nonce); domStrategy.insertElement = jest.fn((el: HTMLLinkElement) => { setTimeout(() => { @@ -81,23 +69,16 @@ describe('StyleLoadingStrategy', () => { new CustomEvent('success', { detail: { crossOrigin: el.crossOrigin, - nonce: el.getAttribute('nonce'), }, }), ); }, 0); }) as any; - const strategy = new StyleLoadingStrategy( - path, - domStrategy, - crossOriginStrategy, - contentSecurityStrategy, - ); + const strategy = new StyleLoadingStrategy(path, domStrategy, crossOriginStrategy); strategy.createStream().subscribe(event => { expect(event.detail.crossOrigin).toBe('use-credentials'); - expect(event.detail.nonce).toBe(nonce); done(); }); }); diff --git a/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts b/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts index 09b4ec4e4f..f602bf06ef 100644 --- a/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts +++ b/npm/ng-packs/packages/core/src/lib/utils/lazy-load-utils.ts @@ -1,8 +1,4 @@ import { Observable, Observer } from 'rxjs'; -import { - ContentSecurityStrategy, - CONTENT_SECURITY_STRATEGY, -} from '../strategies/content-security.strategy'; import { CrossOriginStrategy, CROSS_ORIGIN_STRATEGY } from '../strategies/cross-origin.strategy'; import { DomStrategy, DOM_STRATEGY } from '../strategies/dom.strategy'; @@ -10,10 +6,8 @@ export function fromLazyLoad( element: HTMLScriptElement | HTMLLinkElement, domStrategy: DomStrategy = DOM_STRATEGY.AppendToHead(), crossOriginStrategy: CrossOriginStrategy = CROSS_ORIGIN_STRATEGY.Anonymous(), - contentSecurityStrategy: ContentSecurityStrategy = CONTENT_SECURITY_STRATEGY.Loose(), ): Observable { crossOriginStrategy.setCrossOrigin(element); - contentSecurityStrategy.applyCSP(element); domStrategy.insertElement(element); return new Observable((observer: Observer) => { From 2b50598a9163bfcc7dac72d464c859f8ec70a9ff Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Thu, 2 Apr 2020 20:00:19 +0300 Subject: [PATCH 14/20] feat(core): rename strategies based on security perspective --- .../strategies/content-security.strategy.ts | 12 ++++++------ .../tests/content-security.strategy.spec.ts | 18 +++++++++--------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/npm/ng-packs/packages/core/src/lib/strategies/content-security.strategy.ts b/npm/ng-packs/packages/core/src/lib/strategies/content-security.strategy.ts index 54016b5836..2875916ef2 100644 --- a/npm/ng-packs/packages/core/src/lib/strategies/content-security.strategy.ts +++ b/npm/ng-packs/packages/core/src/lib/strategies/content-security.strategy.ts @@ -4,7 +4,7 @@ export abstract class ContentSecurityStrategy { abstract applyCSP(element: HTMLScriptElement | HTMLStyleElement): void; } -export class StrictContentSecurityStrategy extends ContentSecurityStrategy { +export class LooseContentSecurityStrategy extends ContentSecurityStrategy { constructor(nonce: string) { super(nonce); } @@ -14,7 +14,7 @@ export class StrictContentSecurityStrategy extends ContentSecurityStrategy { } } -export class LooseContentSecurityStrategy extends ContentSecurityStrategy { +export class StrictContentSecurityStrategy extends ContentSecurityStrategy { constructor() { super(); } @@ -23,10 +23,10 @@ export class LooseContentSecurityStrategy extends ContentSecurityStrategy { } export const CONTENT_SECURITY_STRATEGY = { - Loose() { - return new LooseContentSecurityStrategy(); + Loose(nonce: string) { + return new LooseContentSecurityStrategy(nonce); }, - Strict(nonce: string) { - return new StrictContentSecurityStrategy(nonce); + Strict() { + return new StrictContentSecurityStrategy(); }, }; diff --git a/npm/ng-packs/packages/core/src/lib/tests/content-security.strategy.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/content-security.strategy.spec.ts index 06db799ca0..c617e35893 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/content-security.strategy.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/content-security.strategy.spec.ts @@ -7,25 +7,25 @@ import { uuid } from '../utils'; describe('LooseContentSecurityStrategy', () => { describe('#applyCSP', () => { - it('should not set nonce attribute', () => { - const strategy = new LooseContentSecurityStrategy(); + it('should set nonce attribute', () => { + const nonce = uuid(); + const strategy = new LooseContentSecurityStrategy(nonce); const element = document.createElement('link'); strategy.applyCSP(element); - expect(element.getAttribute('nonce')).toBeNull(); + expect(element.getAttribute('nonce')).toBe(nonce); }); }); }); describe('StrictContentSecurityStrategy', () => { describe('#applyCSP', () => { - it('should set nonce attribute', () => { - const nonce = uuid(); - const strategy = new StrictContentSecurityStrategy(nonce); + it('should not set nonce attribute', () => { + const strategy = new StrictContentSecurityStrategy(); const element = document.createElement('link'); strategy.applyCSP(element); - expect(element.getAttribute('nonce')).toBe(nonce); + expect(element.getAttribute('nonce')).toBeNull(); }); }); }); @@ -33,8 +33,8 @@ describe('StrictContentSecurityStrategy', () => { describe('CONTENT_SECURITY_STRATEGY', () => { test.each` name | Strategy | nonce - ${'Loose'} | ${LooseContentSecurityStrategy} | ${undefined} - ${'Strict'} | ${StrictContentSecurityStrategy} | ${uuid()} + ${'Loose'} | ${LooseContentSecurityStrategy} | ${uuid()} + ${'Strict'} | ${StrictContentSecurityStrategy} | ${undefined} `('should successfully map $name to $Strategy.name', ({ name, Strategy, nonce }) => { expect(CONTENT_SECURITY_STRATEGY[name](nonce)).toEqual(new Strategy(nonce)); }); From b15ea010a9c183fff29737e77d7ef8480829cea0 Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Thu, 2 Apr 2020 20:01:15 +0300 Subject: [PATCH 15/20] docs(core): add how content security strategies work --- .../UI/Angular/Content-Security-Strategy.md | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 docs/en/UI/Angular/Content-Security-Strategy.md diff --git a/docs/en/UI/Angular/Content-Security-Strategy.md b/docs/en/UI/Angular/Content-Security-Strategy.md new file mode 100644 index 0000000000..96418a04cb --- /dev/null +++ b/docs/en/UI/Angular/Content-Security-Strategy.md @@ -0,0 +1,53 @@ +# ContentSecurityStrategy + +`ContentSecurityStrategy` is an abstract class exposed by @abp/ng.core package. Its instances help you mark inline script or styles as safe in terms of [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy). + + + + +## API + + +### constructor(public nonce?: string) + +`nonce` enables whitelisting inline script or styles in order to avoid using `unsafe-inline` in [script-src](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#Unsafe_inline_script) and [style-src](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/style-src#Unsafe_inline_styles) directives. + + +### applyCSP(element: HTMLScriptElement | HTMLStyleElement): void + +This method maps the aforementioned properties to the given `element`. + + + +## LooseContentSecurityPolicy + +`LooseContentSecurityPolicy` is a class that extends `ContentSecurityStrategy`. It required `nonce` and marks given `