DEV Community

Ferdie Sletering
Ferdie Sletering

Posted on

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

Intro

We want to make our applications as safe as possible, so we implement a content security policy(CSP) to mitigate Cross Site Scripting (XSS) attacks or Click Jacking.

The demo application contains an ngx-bootstrap toggle and a Angular Material slider component.

application

Alt Text

Implement the Content Security Policy(CSP)

Let's implement a CSP header. More information about CSP.

<meta http-equiv="Content-Security-Policy" content="default-src 'self'">
Enter fullscreen mode Exit fullscreen mode

The result is that our application doesn't look the same anymore.

application

Alt Text

What happened?

Due to our CSP policy, the browser blocks all inline styling that comes from an untrusted source.

console.log

Alt Text

Angular Material and ngx-bootstrap styles are added with the styleUrls property. Angular will parse the component's styling and add them to the

of the page. Based on the ViewEncapsulation property, it's global(none) or scoped(emulated).

<style>

Alt Text

How to solve

When we have control of our styling, we could place all our CSS into a separate file. Issue solved! However, we don't have control over how libraries handle their styling.

Nonce approach

Allows an inline script or CSS to execute if the script (e.g.: <style nonce=" r@nd0m">) tag contains a nonce attribute matching the nonce specified in the CSP header. The nonce should be a secure random string and should not be reused.

Let's add a nonce to our CPS policy and style tags, so our inline styling comes from a trusted source.

 <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'nonce-random-csp-nonce';">
Enter fullscreen mode Exit fullscreen mode

Add nonce to style tag

When looking into the angular/platform-browser package, the following code is responsible for injecting the style tags.

shared_styles_host.ts

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

Luckily Angular provides us with dependency providers, which allows us to create a custom _addStylesToHost function.

We copy the shared_styles_host.ts and modify the _addStylesToHost method.

 private _addStylesToHost(
    styles: Set<string>,
    host: Node,
    styleNodes: Node[]
  ): void {
    styles.forEach((style: string) => {
      const styleEl = this._doc.createElement('style');
      styleEl.textContent = style;
      styleEl.setAttribute('nonce', 'random-csp-nonce'); // Add nonce
      styleNodes.push(host.appendChild(styleEl));
    });
  }
Enter fullscreen mode Exit fullscreen mode

We create a module that can be imported in our app.module.ts

inline-styles-csp.module.ts

import { NgModule } from '@angular/core';
import { CustomDomSharedStylesHost } from './shared_styles_host';
import { ɵDomSharedStylesHost } from '@angular/platform-browser';

@NgModule({
  providers: [
    { provide: ɵDomSharedStylesHost, useClass: CustomDomSharedStylesHost },
  ],
})
export class InlineStylesCSPModule {}

Enter fullscreen mode Exit fullscreen mode

After applying these changes, the style tag contains a nonce.

styletag with nonce

image

We now have a static nonce that is not secure.

The nonce should be a secure random string and should not be reused.

Create a secure random string with Nginx

We use the sub_filter module of Nginx to replace the static with a dynamic string. In that case, we use the Nginx $request_id variable.

nginx.conf

sub_filter_once off;
sub_filter random-csp-nonce $request_id;

add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'nonce-$request_id'";
Enter fullscreen mode Exit fullscreen mode

Also, note we add the add_header to our config file.

Still, our solution doesn't work because Nginx replaces random-csp-nonce on the index.html file. Angular adds the style tags to the document after Nginx serves the document. When we place a hard-coded <style nonce="random-csp-nonce" /> in the index.html it gets replaced with a dynamic nonce.

Add metatag

We add a new metatag to the index.html so our script can look up the dynamic nonce value.

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="CSP-NONCE" content="random-csp-nonce"/>
</head>
<body>
  <app-root></app-root>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Let's update our _addStylesToHost method to query the nonce.

 private _addStylesToHost(
    styles: Set<string>,
    host: Node,
    styleNodes: Node[]
  ): void {
    const nonce = document
      .querySelector('meta[name="CSP-NONCE"]')
      ?.getAttribute('content');
    styles.forEach((style: string) => {
      const styleEl = this._doc.createElement('style');
      styleEl.textContent = style;
      styleEl.setAttribute('nonce', nonce); // Add nonce
      styleNodes.push(host.appendChild(styleEl));
    });
  }
Enter fullscreen mode Exit fullscreen mode

Each time we reload the page, a new random nonce is generated and applied to all style tags.

random nonce styletag

Alt Text

Our application looks the same as from the beginning. But now, we have applied a CSP policy :).

application

Alt Text

Conclusion

Although we have proof of concept on fixing the inline styles issue, the final and more sustainable solution should come from the community and the Angular team. For now, we have to inject a custom DomSharedStylesHost class.

Demo

The Github code contains a full version of the code. However, for demo purposes, some code is stripped out of the original code.

🌎 Live demo
📝 Github

Improvements

Nonce in style tags remains empty

The browser parses the inline styles, but the actual nonce remains empty. I don't know why this occurs. The response Content-Security-Policy header contains the correct nonce value.

Stronger nonce token

The $request_id is not a cryptographically secure random token. We could improve this with an nginx module as Scott Helme suggests.

100% secure?

The fact we use AOT compilation means all code is already compiled and can't be tampered with. So although the code looks for the CSP header and gets the nonce, any other script that gets executed could do the same.

Any feedback or thoughts are welcome.

Top comments (9)

Collapse
 
ryandmello1198 profile image
Ryan

I've extended this solution for express static and wrote about it here medium.com/@dmelloryan11/how-to-im...

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?

Collapse
 
ewolfman profile image
ewolfman • Edited

Thanks a lot for this. Very nice and cool.

However, although I implemented the solution you described, I am still getting CSP errors on elements (Angular). <br> I can see the correct CSP header with the &#39;nonce-123&#39; (123 is just an example of course..), and I have logged the nonce value being set on the style elements to ensure I placed the correct one. What is strange is that both Chrome and Firefox show a <style nonce> but without the nonce code itself, and the console shows CSP errors on style-src. When I try writing a dummy attribute with the same value, for the sake of troubleshooting, I can see the nonce value properly, for example: <style test="123">.</p> <p>Any idea?</p>

Collapse
 
ryandmello1198 profile image
Ryan • Edited

Thank you, this helped a lot. However, in your demo check console log, some styles are blocked.

Collapse
 
ferdiesletering profile image
Ferdie Sletering

Glad could help, one style is intentionally blocked for demo purposes.

Collapse
 
rm_vanengelen_9134e3297c profile image
RM van Engelen

Thank you, looks like a clear approach. What version of Angular are you working with?
As I can only find a shared_styles_hosts.js in Angular 11. Or do I look at the wrong spot?

Collapse
 
wallace41290 profile image
Will Davis

This may be a dumb question, but what is stopping an XSS attack from grabbing the nonce value off the meta tag too?