Skip to content

Commit b975da4

Browse files
authored
fix(@angular/build): add CSP nonce to stylesheet and modulepreload link elements
Under strict Content Security Policies that do not allow 'self' in style-src or style-src-elem, external stylesheets must be authorized via a cryptographic nonce.
1 parent 32cb8be commit b975da4

5 files changed

Lines changed: 92 additions & 8 deletions

File tree

‎packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
170170
indexFileContent.toContain('app-shell works!');
171171
indexFileContent.toContain('p{color:#000}');
172172
indexFileContent.toContain(
173-
`<link rel="stylesheet" href="styles.css" media="print" data-beasties-media="all">`,
173+
`<link rel="stylesheet" href="styles.css" nonce="{% nonce %}" media="print" data-beasties-media="all">`,
174174
);
175175
indexFileContent.toContain('<style nonce="{% nonce %}">p{color:#000}');
176176
indexFileContent.toContain('<style nonce="{% nonce %}" ng-app-id="ng">');

‎packages/angular/build/src/utils/index-file/inline-critical-css_spec.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,4 +233,31 @@ describe('inlineCriticalCss', () => {
233233
expect(warnings.length).toBeGreaterThan(0);
234234
expect(warnings[0]).toContain('missing.css');
235235
});
236+
237+
it('should retain nonce on stylesheet links and noscript fallback', async () => {
238+
const initialContent = `
239+
<html>
240+
<head>
241+
<link href="styles.css" rel="stylesheet" nonce="test-nonce">
242+
</head>
243+
<body></body>
244+
</html>`;
245+
246+
const { content, errors, warnings } = await inlineCriticalCss(
247+
initialContent,
248+
'/dist/',
249+
undefined,
250+
false,
251+
readAsset,
252+
);
253+
254+
expect(errors).toEqual([]);
255+
expect(warnings).toEqual([]);
256+
expect(content).toContain(
257+
'<link href="styles.css" rel="stylesheet" nonce="test-nonce" media="print" data-beasties-media="all">',
258+
);
259+
expect(content).toContain(
260+
'<noscript><link href="styles.css" rel="stylesheet" nonce="test-nonce"></noscript>',
261+
);
262+
});
236263
});

‎packages/angular/build/src/utils/index-file/nonce.ts‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@ import { htmlRewritingStream } from './html-rewriting-stream';
1414
*/
1515
const NONCE_ATTR_PATTERN = /ngCspNonce/i;
1616

17+
const TARGET_LINK_RELS: ReadonlySet<string> = new Set(['stylesheet', 'modulepreload']);
18+
1719
/**
18-
* Finds the `ngCspNonce` value and copies it to all inline `<style>` and `<script> `tags.
20+
* Finds the `ngCspNonce` value and copies it to all `<style>` and `<script>` tags,
21+
* as well as stylesheet and modulepreload `<link>` tags.
1922
* @param html Markup that should be processed.
2023
*/
2124
export async function addNonce(html: string): Promise<string> {
@@ -28,11 +31,29 @@ export async function addNonce(html: string): Promise<string> {
2831
const { rewriter, transformedContent } = await htmlRewritingStream(html);
2932

3033
rewriter.on('startTag', (tag) => {
31-
if (
32-
(tag.tagName === 'style' || tag.tagName === 'script') &&
33-
!tag.attrs.some((attr) => attr.name === 'nonce')
34-
) {
35-
tag.attrs.push({ name: 'nonce', value: nonce });
34+
const { tagName, attrs } = tag;
35+
36+
if (tagName === 'style' || tagName === 'script' || tagName === 'link') {
37+
let isTarget = tagName !== 'link';
38+
let hasNonce = false;
39+
40+
for (const attr of attrs) {
41+
if (attr.name === 'nonce') {
42+
hasNonce = true;
43+
break;
44+
}
45+
46+
if (tagName === 'link' && attr.name === 'rel' && attr.value) {
47+
const tokens = attr.value.trim().toLowerCase().split(/\s+/);
48+
if (tokens.some((token) => TARGET_LINK_RELS.has(token))) {
49+
isTarget = true;
50+
}
51+
}
52+
}
53+
54+
if (isTarget && !hasNonce) {
55+
attrs.push({ name: 'nonce', value: nonce });
56+
}
3657
}
3758

3859
rewriter.emitStartTag(tag);

‎packages/angular/build/src/utils/index-file/nonce_spec.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,40 @@ describe('addNonce', () => {
9292
expect(result).toContain('<script src="./main.js" nonce="{% nonce %}"></script>');
9393
expect(result).toContain(`<script nonce="{% nonce %}">console.log('bar');</script>`);
9494
});
95+
96+
it('should add the nonce expression to stylesheet and modulepreload link tags', async () => {
97+
const result = await addNonce(`
98+
<html>
99+
<head>
100+
<link rel="stylesheet" href="styles.css">
101+
<link rel="modulepreload" href="main.js">
102+
<link rel="preload" href="font.woff2" as="font">
103+
</head>
104+
<body>
105+
<app ngCspNonce="{% nonce %}"></app>
106+
</body>
107+
</html>
108+
`);
109+
110+
expect(result).toContain('<link rel="stylesheet" href="styles.css" nonce="{% nonce %}">');
111+
expect(result).toContain('<link rel="modulepreload" href="main.js" nonce="{% nonce %}">');
112+
expect(result).toContain('<link rel="preload" href="font.woff2" as="font">');
113+
});
114+
115+
it('should add the nonce expression when link rel has mixed case or multiple tokens', async () => {
116+
const result = await addNonce(`
117+
<html>
118+
<head>
119+
<link rel="Stylesheet" href="styles.css">
120+
<link rel="MODULEPRELOAD" href="main.js">
121+
</head>
122+
<body>
123+
<app ngCspNonce="{% nonce %}"></app>
124+
</body>
125+
</html>
126+
`);
127+
128+
expect(result).toContain('<link rel="Stylesheet" href="styles.css" nonce="{% nonce %}">');
129+
expect(result).toContain('<link rel="MODULEPRELOAD" href="main.js" nonce="{% nonce %}">');
130+
});
95131
});

‎packages/angular_devkit/build_angular/src/builders/app-shell/app-shell_spec.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ describe('AppShell Builder', () => {
195195
expect(content).toContain('<app-root ngcspnonce="{% nonce %}"');
196196
expect(content).toContain('<script nonce="{% nonce %}">');
197197
expect(content).toMatch(
198-
/<link rel="stylesheet" href="styles\.[a-z0-9]+\.css" media="print" data-beasties-media="all">/,
198+
/<link rel="stylesheet" href="styles\.[a-z0-9]+\.css" nonce="{% nonce %}" media="print" data-beasties-media="all">/,
199199
);
200200
});
201201
});

0 commit comments

Comments
 (0)