Skip to content

Commit 152b8b9

Browse files
committed
fix(@angular-devkit/build-angular): support CSP on critical CSS link 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.
1 parent 659baf7 commit 152b8b9

File tree

3 files changed

+82
-7
lines changed

3 files changed

+82
-7
lines changed

packages/angular_devkit/build_angular/src/utils/index-file/inline-critical-css.ts

+2
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ class CrittersExtended extends Critters {
4141
pruneSource: false,
4242
reduceInlineStyles: false,
4343
mergeStylesheets: false,
44+
// Note: if `preload` changes to anything other than `media`, the logic in the `addStyleNonce`
45+
// processor which removes the `onload` handler from `link` tags has to be updated to match.
4446
preload: 'media',
4547
noscriptFallback: true,
4648
inlineFonts: true,

packages/angular_devkit/build_angular/src/utils/index-file/style-nonce.ts

+54-7
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,35 @@ import { htmlRewritingStream } from './html-rewriting-stream';
1515
const NONCE_ATTR_PATTERN = /ngCspNonce/i;
1616

1717
/**
18-
* Finds the `ngCspNonce` value and copies it to all inline `<style>` tags.
18+
* Pattern used to extract the media query set by Critters in an `onload` handler.
19+
*/
20+
const MEDIA_SET_HANDLER_PATTERN = /^this\.media=["'](.*)["'];?$/;
21+
22+
/**
23+
* Name of the attribute used to save the Critters media query so it can be re-assigned on load.
24+
*/
25+
const CSP_MEDIA_ATTR = 'ngCspMedia';
26+
27+
/**
28+
* Content of the script that swaps out the critical CSS
29+
* media query at runtime in a CSP-compliant way.
30+
*/
31+
const CSP_SCRIPT_CONTENT = `
32+
(function() {
33+
var children = document.head.children;
34+
function onLoad() {this.media = this.getAttribute('${CSP_MEDIA_ATTR}');}
35+
for (var i = 0; i < children.length; i++) {
36+
var child = children[i];
37+
child.hasAttribute('${CSP_MEDIA_ATTR}') && child.addEventListener('load', onLoad);
38+
}
39+
})();
40+
`;
41+
42+
/**
43+
* Finds the `ngCspNonce` value and:
44+
* 1. Copies the nonce to all inline `<style>` tags.
45+
* 2. Removes `onload` handlers generated by Critters from `link` tags and adds a `<script>` tag
46+
* to the head that replicates the same behavior.
1947
* @param html Markup that should be processed.
2048
*/
2149
export async function addStyleNonce(html: string): Promise<string> {
@@ -26,14 +54,33 @@ export async function addStyleNonce(html: string): Promise<string> {
2654
}
2755

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

30-
rewriter.on('startTag', (tag) => {
31-
if (tag.tagName === 'style' && !tag.attrs.some((attr) => attr.name === 'nonce')) {
32-
tag.attrs.push({ name: 'nonce', value: nonce });
33-
}
59+
rewriter
60+
.on('startTag', (tag) => {
61+
if (tag.tagName === 'style' && !tag.attrs.some((attr) => attr.name === 'nonce')) {
62+
tag.attrs.push({ name: 'nonce', value: nonce });
63+
} else if (tag.tagName === 'link') {
64+
const onloadAttr = tag.attrs.find((tag) => tag.name === 'onload');
65+
const mediaMatch = onloadAttr?.value.match(MEDIA_SET_HANDLER_PATTERN);
3466

35-
rewriter.emitStartTag(tag);
36-
});
67+
if (mediaMatch) {
68+
hasOnloadLinkTags = true;
69+
tag.attrs = tag.attrs.map((attr) =>
70+
attr === onloadAttr ? { name: CSP_MEDIA_ATTR, value: mediaMatch[1] } : attr,
71+
);
72+
}
73+
}
74+
75+
rewriter.emitStartTag(tag);
76+
})
77+
.on('endTag', (tag) => {
78+
if (hasOnloadLinkTags && tag.tagName === 'head') {
79+
rewriter.emitRaw(`<script nonce="${nonce}">${CSP_SCRIPT_CONTENT}</script>`);
80+
}
81+
82+
rewriter.emitEndTag(tag);
83+
});
3784

3885
return transformedContent();
3986
}

packages/angular_devkit/build_angular/src/utils/index-file/style-nonce_spec.ts

+26
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,30 @@ describe('add-style-nonce', () => {
7373

7474
expect(result).toContain('<style nonce="{% nonce %}">.a {color: red;}</style>');
7575
});
76+
77+
it('should add the nonce expression to link tags with Critters onload tags', async () => {
78+
const result = await addStyleNonce(
79+
`
80+
<html>
81+
<head>
82+
<link href="http://cdn.com/lib.css" rel="stylesheet" media="print" ` +
83+
`onload="this.media='(min-height: 680px), screen and (orientation: portrait)'">
84+
<link href="styles.css" rel="stylesheet" media="print" onload="this.media='all'">
85+
</head>
86+
<body>
87+
<app ngCspNonce="{% nonce %}"></app>
88+
</body>
89+
</html>
90+
`,
91+
);
92+
93+
expect(result).toContain('<script nonce="{% nonce %}">');
94+
expect(result).toContain(
95+
`<link href="http://cdn.com/lib.css" rel="stylesheet" media="print" ` +
96+
`ngCspMedia="(min-height: 680px), screen and (orientation: portrait)">`,
97+
);
98+
expect(result).toContain(
99+
`<link href="styles.css" rel="stylesheet" media="print" ngCspMedia="all">`,
100+
);
101+
});
76102
});

0 commit comments

Comments
 (0)