DEV Community

Discussion on: How to implement an inline styles Content Security Policy with Angular and Nginx

Collapse
 
nikhil_hukkerikar profile image
Nikhil • Edited

For Angular 14 + Material there's another issue with media matcher style tag injection below are the monkey patches to these injectors to provide our custom nonce

Media Matcher override

inline-styles-csp\media-matcher.ts

import { Inject, Injectable } from '@angular/core';
import { Platform } from '@angular/cdk/platform';
import { CustomDomSharedStylesHost } from './shared_styles_host';
import { ɵDomSharedStylesHost } from '@angular/platform-browser';

/** Global registry for all dynamically-created, injected media queries. */
const mediaQueriesForWebkitCompatibility: Set<string> = new Set<string>();

/** Style tag that holds all of the dynamically-created media queries. */
let mediaQueryStyleNode: HTMLStyleElement | undefined;

@Injectable()
export class CustomMediaMatcher {
    /** The internal matchMedia method to return back a MediaQueryList like object. */
    private _matchMedia: (query: string) => MediaQueryList;

    constructor(private _platform: Platform, @Inject(ɵDomSharedStylesHost) private customDomSharedStylesHost: CustomDomSharedStylesHost) {
        this._matchMedia =
            this._platform.isBrowser && window.matchMedia
                ? // matchMedia is bound to the window scope intentionally as it is an illegal invocation to
                // call it from a different scope.
                window.matchMedia.bind(window)
                : noopMatchMedia;
    }

    /**
     * Evaluates the given media query and returns the native MediaQueryList from which results
     * can be retrieved.
     * Confirms the layout engine will trigger for the selector query provided and returns the
     * MediaQueryList for the query provided.
     */
    matchMedia(query: string): MediaQueryList {
        if (this._platform.WEBKIT || this._platform.BLINK) {
            createEmptyStyleRule(query, this.customDomSharedStylesHost.nonce);
        }
        return this._matchMedia(query);
    }
}

/**
 * Creates an empty stylesheet that is used to work around browser inconsistencies related to
 * `matchMedia`. At the time of writing, it handles the following cases:
 * 1. On WebKit browsers, a media query has to have at least one rule in order for `matchMedia`
 * to fire. We work around it by declaring a dummy stylesheet with a `@media` declaration.
 * 2. In some cases Blink browsers will stop firing the `matchMedia` listener if none of the rules
 * inside the `@media` match existing elements on the page. We work around it by having one rule
 * targeting the `body`. See https://github.com/angular/components/issues/23546.
 */
function createEmptyStyleRule(query: string, nonce: string | null | undefined) {
    if (mediaQueriesForWebkitCompatibility.has(query)) {
        return;
    }

    try {
        if (!mediaQueryStyleNode) {
            mediaQueryStyleNode = document.createElement('style');
            mediaQueryStyleNode.setAttribute('type', 'text/css');
            if (!!nonce) {
                mediaQueryStyleNode.setAttribute('nonce', nonce);
            }
            document.head!.appendChild(mediaQueryStyleNode);
        }

        if (mediaQueryStyleNode.sheet) {
            mediaQueryStyleNode.sheet.insertRule(`@media ${query} {body{ }}`, 0);
            mediaQueriesForWebkitCompatibility.add(query);
        }
    } catch (e) {
        console.error(e);
    }
}

/** No-op matchMedia replacement for non-browser platforms. */
function noopMatchMedia(query: string): MediaQueryList {
    // Use `as any` here to avoid adding additional necessary properties for
    // the noop matcher.
    return {
        matches: query === 'all' || query === '',
        media: query,
        addListener: () => { },
        removeListener: () => { },
    } as any;
}
Enter fullscreen mode Exit fullscreen mode

Updated shared styles host

inline-styles-csp\shared_styles_host.ts

import { DOCUMENT, ɵgetDOM as getDOM } from '@angular/common';
import { Inject, Injectable, OnDestroy } from '@angular/core';
import { ɵSharedStylesHost } from '@angular/platform-browser';

@Injectable()
export class CustomDomSharedStylesHost
  extends ɵSharedStylesHost
  implements OnDestroy
{
  // Maps all registered host nodes to a list of style nodes that have been added to the host node.
  private _hostNodes = new Map<Node, Node[]>();
  private _nonce: string | null | undefined = null;

  constructor(
    @Inject(DOCUMENT) private _doc: any,
    @Inject('cspMetaSelector')
    private _metaCSPTag: string
  ) {
    super();
    this._hostNodes.set(_doc.head, []);
    this._setCSPNonce();
  }

  private _addStylesToHost(
    styles: Set<string>,
    host: Node,
    styleNodes: Node[]
  ): void {
    styles.forEach((style: string) => {
      const styleEl = this._doc.createElement('style');
      styleEl.textContent = style;

      if (!style.includes('without-nonce') && this._nonce) {
        styleEl.setAttribute('nonce', this._nonce);
      }

      styleNodes.push(host.appendChild(styleEl));
    });

    if (this._nonce) {
      this._removeCSPNonceHeader();
    }
  }

  private _setCSPNonce(): void {
    this._nonce = document
      .querySelector(this._metaCSPTag)
      ?.getAttribute('content');
  }

  private _removeCSPNonceHeader(): void {
    document.querySelector(this._metaCSPTag)?.remove();
  }

  addHost(hostNode: Node): void {
    const styleNodes: Node[] = [];
    this._hostNodes.set(hostNode, styleNodes);
  }

  removeHost(hostNode: Node): void {
    const styleNodes = this._hostNodes.get(hostNode);
    if (styleNodes) {
      styleNodes.forEach(removeStyle);
    }
    this._hostNodes.delete(hostNode);
  }

  override onStylesAdded(additions: Set<string>): void {
    this._hostNodes.forEach((styleNodes, hostNode) => {
      this._addStylesToHost(additions, hostNode, styleNodes);
    });
  }

  get nonce(): string | null | undefined {
    return this._nonce;
  }

  ngOnDestroy(): void {
    this._hostNodes.forEach((styleNodes) => styleNodes.forEach(removeStyle));
  }
}

function removeStyle(styleNode: Node): void {
  getDOM().remove(styleNode);
}
Enter fullscreen mode Exit fullscreen mode

Updated module

inline-styles-csp\inline-styles-csp.module.ts

import { NgModule } from '@angular/core';
import { CustomDomSharedStylesHost } from './shared_styles_host';
import { ɵDomSharedStylesHost } from '@angular/platform-browser';
import { MediaMatcher } from '@angular/cdk/layout';
import { CustomMediaMatcher } from './media-matcher';
@NgModule({
  providers: [
    { provide: 'cspMetaSelector', useValue: 'meta[name="CSP-NONCE"]' },
    { provide: ɵDomSharedStylesHost, useClass: CustomDomSharedStylesHost },
    { provide: MediaMatcher, useClass: CustomMediaMatcher}
  ],
})
export class InlineStylesCSPModule {}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
kkxiaoa profile image
Kuangkuang Xiao

thanks,it is useful for me, but how implement inject the CSP Nonce value in the inline style generated by the third-party library in angular?