From 4cc3a272ebe8a207d2cb4896195970c241feed9c Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Thu, 2 Apr 2020 13:20:08 +0300 Subject: [PATCH] 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) => {