Skip to content

Commit

Permalink
fix(@angular-devkit/build-angular): support CSP on critical CSS link …
Browse files Browse the repository at this point in the history
…tags.

Based on angular#24880 (review). Critters can generate `link` tags with inline `onload` handlers which breaks CSP. These changes update the style nonce processor to remove the `onload` handlers and replicate the behavior with an inline `script` tag that gets the proper nonce.

Note that earlier we talked about doing this through Critters which while possible, would still require a custom HTML processor, because we need to both add and remove attributes from an element.
  • Loading branch information
crisbeto committed Mar 24, 2023
1 parent 659baf7 commit ab1415b
Show file tree
Hide file tree
Showing 2 changed files with 125 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ import * as fs from 'fs';

const Critters: typeof import('critters').default = require('critters');

/**
* Pattern used to extract the media query set by Critters in an `onload` handler.
*/
const MEDIA_SET_HANDLER_PATTERN = /^this\.media=["'](.*)["'];?$/;

/**
* Name of the attribute used to save the Critters media query so it can be re-assigned on load.
*/
const CSP_MEDIA_ATTR = 'ngCspMedia';

export interface InlineCriticalCssProcessOptions {
outputPath: string;
}
Expand All @@ -20,9 +30,36 @@ export interface InlineCriticalCssProcessorOptions {
readAsset?: (path: string) => Promise<string>;
}

/** Partial representation of an `HTMLElement`. */
interface PartialHTMLElement {
getAttribute(name: string): string | null;
setAttribute(name: string, value: string): void;
removeAttribute(name: string): void;
appendChild(child: PartialHTMLElement): void;
textContent: string;
}

/** Partial representation of an HTML `Document`. */
interface PartialDocument {
head: PartialHTMLElement;
createElement(tagName: string): PartialHTMLElement;
querySelector(selector: string): PartialHTMLElement | null;
}

/** Signature of the `Critters.embedLinkedStylesheet` method. */
type EmbedLinkedStylesheetFn = (
link: PartialHTMLElement,
document: PartialDocument,
) => Promise<unknown>;

class CrittersExtended extends Critters {
readonly warnings: string[] = [];
readonly errors: string[] = [];
private initialEmbedLinkedStylesheet: EmbedLinkedStylesheetFn;
private addedCspScriptsDocuments = new WeakSet<PartialDocument>();

// Inherited from `Critters`, but not exposed in the typings.
protected embedLinkedStylesheet!: EmbedLinkedStylesheetFn;

constructor(
private readonly optionsExtended: InlineCriticalCssProcessorOptions &
Expand All @@ -41,17 +78,82 @@ class CrittersExtended extends Critters {
pruneSource: false,
reduceInlineStyles: false,
mergeStylesheets: false,
// Note: if `preload` changes to anything other than `media`, the logic in
// `embedLinkedStylesheetOverride` will have to be updated.
preload: 'media',
noscriptFallback: true,
inlineFonts: true,
});

// We can't use inheritance to override `embedLinkedStylesheet`, because it's not declared in
// the `Critters` .d.ts which means that we can't call the `super` implementation. TS doesn't
// allow for `super` to be cast to a different type.
this.initialEmbedLinkedStylesheet = this.embedLinkedStylesheet;
this.embedLinkedStylesheet = this.embedLinkedStylesheetOverride;
}

public override readFile(path: string): Promise<string> {
const readAsset = this.optionsExtended.readAsset;

return readAsset ? readAsset(path) : fs.promises.readFile(path, 'utf-8');
}

/**
* Override of the Critters `embedLinkedStylesheet` method
* that makes it work with Angular's CSP APIs.
*/
private embedLinkedStylesheetOverride: EmbedLinkedStylesheetFn = async (link, document) => {
const returnValue = await this.initialEmbedLinkedStylesheet(link, document);
const crittersMedia = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN);

if (crittersMedia) {
// HTML attribute are case-insensitive, but the parser
// used by Critters appears to be case-sensitive.
const nonceElement = document.querySelector('[ngCspNonce], [ngcspnonce]');
const cspNonce =
nonceElement?.getAttribute('ngCspNonce') || nonceElement?.getAttribute('ngcspnonce');

if (cspNonce) {
// If there's a Critters-generated `onload` handler and the file has an Angular CSP nonce,
// we have to remove the handler, because it's incompatible with CSP. We save the value
// in a different attribute and we generate a script tag with the nonce that uses
// `addEventListener` to apply the media query instead.
link.removeAttribute('onload');
link.setAttribute(CSP_MEDIA_ATTR, crittersMedia[1]);
this.conditionallyInsertCspLoadingScript(document, cspNonce);
}
}

return returnValue;
};

/**
* Inserts the `script` tag that swaps the critical CSS at runtime,
* if one hasn't been inserted into the document already.
*/
private conditionallyInsertCspLoadingScript(document: PartialDocument, nonce: string) {
if (!this.addedCspScriptsDocuments.has(document)) {
const script = document.createElement('script');
script.setAttribute('nonce', nonce);
script.textContent = [
`(function() {`,
// Save the `children` in a variable since they're a live DOM node collection.
` var children = document.head.children;`,
// Declare `onLoad` outside the loop to avoid leaking memory.
// Can't be an arrow function, because we need `this` to refer to the DOM node.
` function onLoad() {this.media = this.getAttribute('${CSP_MEDIA_ATTR}');}`,
` for (var i = 0; i < children.length; i++) {`,
` var child = children[i];`,
` child.hasAttribute('${CSP_MEDIA_ATTR}') && child.addEventListener('load', onLoad);`,
` }`,
`})();`,
].join('\n');
// Append the script to the head since it needs to
// run as early as possible, after the `link` tags.
document.head.appendChild(script);
this.addedCspScriptsDocuments.add(document);
}
}
}

export class InlineCriticalCssProcessor {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ describe('InlineCriticalCssProcessor', () => {
throw new Error('Cannot read asset.');
};

const getContent = (deployUrl: string): string => {
const getContent = (deployUrl: string, bodyContent = ''): string => {
return `
<html>
<head>
<link href="${deployUrl}styles.css" rel="stylesheet">
<link href="${deployUrl}theme.css" rel="stylesheet">
</head>
<body></body>
<body>${bodyContent}</body>
</html>`;
};

Expand Down Expand Up @@ -105,4 +105,25 @@ describe('InlineCriticalCssProcessor', () => {
);
expect(content).toContain('<style>body{margin:0}html{color:white}</style>');
});

it('should process the inline `onload` handlers if a CSP nonce is specified', async () => {
const inlineFontsProcessor = new InlineCriticalCssProcessor({
readAsset,
});

const { content } = await inlineFontsProcessor.process(
getContent('', '<app ngCspNonce="{% nonce %}"></app>'),
{
outputPath: '/dist/',
},
);

expect(content).toContain(
'<link href="styles.css" rel="stylesheet" media="print" ngCspMedia="all">',
);
expect(content).toContain(
'<link href="theme.css" rel="stylesheet" media="print" ngCspMedia="all">',
);
expect(content).toContain('<script nonce="{% nonce %}">');
});
});

0 comments on commit ab1415b

Please sign in to comment.