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 152b8b9
Show file tree
Hide file tree
Showing 3 changed files with 82 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ class CrittersExtended extends Critters {
pruneSource: false,
reduceInlineStyles: false,
mergeStylesheets: false,
// Note: if `preload` changes to anything other than `media`, the logic in the `addStyleNonce`
// processor which removes the `onload` handler from `link` tags has to be updated to match.
preload: 'media',
noscriptFallback: true,
inlineFonts: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,35 @@ import { htmlRewritingStream } from './html-rewriting-stream';
const NONCE_ATTR_PATTERN = /ngCspNonce/i;

/**
* Finds the `ngCspNonce` value and copies it to all inline `<style>` tags.
* 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';

/**
* Content of the script that swaps out the critical CSS
* media query at runtime in a CSP-compliant way.
*/
const CSP_SCRIPT_CONTENT = `
(function() {
var children = document.head.children;
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);
}
})();
`;

/**
* Finds the `ngCspNonce` value and:
* 1. Copies the nonce to all inline `<style>` tags.
* 2. Removes `onload` handlers generated by Critters from `link` tags and adds a `<script>` tag
* to the head that replicates the same behavior.
* @param html Markup that should be processed.
*/
export async function addStyleNonce(html: string): Promise<string> {
Expand All @@ -26,14 +54,33 @@ export async function addStyleNonce(html: string): Promise<string> {
}

const { rewriter, transformedContent } = await htmlRewritingStream(html);
let hasOnloadLinkTags = false;

rewriter.on('startTag', (tag) => {
if (tag.tagName === 'style' && !tag.attrs.some((attr) => attr.name === 'nonce')) {
tag.attrs.push({ name: 'nonce', value: nonce });
}
rewriter
.on('startTag', (tag) => {
if (tag.tagName === 'style' && !tag.attrs.some((attr) => attr.name === 'nonce')) {
tag.attrs.push({ name: 'nonce', value: nonce });
} else if (tag.tagName === 'link') {
const onloadAttr = tag.attrs.find((tag) => tag.name === 'onload');
const mediaMatch = onloadAttr?.value.match(MEDIA_SET_HANDLER_PATTERN);

rewriter.emitStartTag(tag);
});
if (mediaMatch) {
hasOnloadLinkTags = true;
tag.attrs = tag.attrs.map((attr) =>
attr === onloadAttr ? { name: CSP_MEDIA_ATTR, value: mediaMatch[1] } : attr,
);
}
}

rewriter.emitStartTag(tag);
})
.on('endTag', (tag) => {
if (hasOnloadLinkTags && tag.tagName === 'head') {
rewriter.emitRaw(`<script nonce="${nonce}">${CSP_SCRIPT_CONTENT}</script>`);
}

rewriter.emitEndTag(tag);
});

return transformedContent();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,30 @@ describe('add-style-nonce', () => {

expect(result).toContain('<style nonce="{% nonce %}">.a {color: red;}</style>');
});

it('should add the nonce expression to link tags with Critters onload tags', async () => {
const result = await addStyleNonce(
`
<html>
<head>
<link href="http://cdn.com/lib.css" rel="stylesheet" media="print" ` +
`onload="this.media='(min-height: 680px), screen and (orientation: portrait)'">
<link href="styles.css" rel="stylesheet" media="print" onload="this.media='all'">
</head>
<body>
<app ngCspNonce="{% nonce %}"></app>
</body>
</html>
`,
);

expect(result).toContain('<script nonce="{% nonce %}">');
expect(result).toContain(
`<link href="http://cdn.com/lib.css" rel="stylesheet" media="print" ` +
`ngCspMedia="(min-height: 680px), screen and (orientation: portrait)">`,
);
expect(result).toContain(
`<link href="styles.css" rel="stylesheet" media="print" ngCspMedia="all">`,
);
});
});

0 comments on commit 152b8b9

Please sign in to comment.