Skip to content

Commit 9cb4688

Browse files
committed
fix(@angular/build): encode script type in auto-CSP loader
createLoaderScript() interpolates four script attributes into the generated loader. integrity and crossOrigin are encoded with JSON.stringify and \u003c, but type is inserted directly into a single-quoted JavaScript string literal. The comment above the function states that type can only be 'module', a JS MIME type or an empty string, but isJavascriptMimeType() only compares the part before the first ';', so a value such as text/javascript;<parameters> reaches the loader unchanged. A quote in that value closes the string literal, and a closing script tag terminates the generated element. Encode type the same way as its neighbours. Both branches of createLoaderScript() share srcListFormatted, so one change covers Trusted Types enabled and disabled.
1 parent 6b20983 commit 9cb4688

2 files changed

Lines changed: 50 additions & 10 deletions

File tree

packages/angular/build/src/utils/index-file/auto-csp.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,10 @@ function createLoaderScript(srcList: SrcScriptTag[], enableTrustedTypes = false)
270270
.map((s) => {
271271
// URI encoding means value can't escape string, JS, or HTML context.
272272
const srcAttr = encodeURI(s.src).replaceAll("'", "\\'");
273-
// Can only be 'module' or a JS MIME type or an empty string.
274-
const typeAttr = s.type ? "'" + s.type + "'" : "''";
273+
// 'module', a JS MIME type, or an empty string. A JS MIME type may carry
274+
// parameters after a ';', which isJavascriptMimeType() does not constrain,
275+
// so encode this the same way as integrity and crossOrigin below.
276+
const typeAttr = JSON.stringify(s.type ?? '').replaceAll('<', '\\u003c');
275277
const asyncAttr = !!s.async;
276278
const deferAttr = !!s.defer;
277279
const integrityAttr = JSON.stringify(s.integrity ?? null).replaceAll('<', '\\u003c');

packages/angular/build/src/utils/index-file/auto-csp_spec.ts

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ describe('auto-csp', () => {
5858
const csps = getCsps(result);
5959
expect(csps).toHaveSize(1);
6060
expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX);
61-
expect(result).toContain(`const scripts = [['./main.js', '', false, false, null, null]];`);
61+
expect(result).toContain(`const scripts = [['./main.js', "", false, false, null, null]];`);
6262
});
6363

6464
it('should rewrite a single source script in place', async () => {
@@ -78,7 +78,7 @@ describe('auto-csp', () => {
7878
expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX);
7979
// Our loader script appears after the HTML text content.
8080
expect(result).toMatch(
81-
/Some text<\/div>\s*<script>\(\(\) => {\s*const scripts = \[\['.\/main.js', '', false, false, null, null\]\];/,
81+
/Some text<\/div>\s*<script>\(\(\) => {\s*const scripts = \[\['.\/main.js', "", false, false, null, null\]\];/,
8282
);
8383
});
8484

@@ -103,7 +103,7 @@ describe('auto-csp', () => {
103103
expect(csps[0]).toMatch(CSP_TWO_HASHES_REGEX);
104104
expect(result).toContain(
105105
// eslint-disable-next-line max-len
106-
`const scripts = [['./main1.js', '', false, false, null, null],['./main2.js', '', true, false, null, null],['./main3.js', 'module', true, true, null, null]];`,
106+
`const scripts = [['./main1.js', "", false, false, null, null],['./main2.js', "", true, false, null, null],['./main3.js', "module", true, true, null, null]];`,
107107
);
108108
// Head loader script is in the head.
109109
expect(result).toContain(`</script></head>`);
@@ -166,12 +166,12 @@ describe('auto-csp', () => {
166166
// Loader script for main.js and main2.js appear after 'foo' and before 'bar'.
167167
expect(result).toMatch(
168168
// eslint-disable-next-line max-len
169-
/console.log\('foo'\);<\/script>\s*<script>\(\(\) => {\s*const scripts = \[\['.\/main.js', '', false, false, null, null\],\['.\/main2.js', '', false, false, null, null\]\];[\s\S]*console.log\('bar'\);/,
169+
/console.log\('foo'\);<\/script>\s*<script>\(\(\) => {\s*const scripts = \[\['.\/main.js', "", false, false, null, null\],\['.\/main2.js', "", false, false, null, null\]\];[\s\S]*console.log\('bar'\);/,
170170
);
171171
// Loader script for main3.js and main4.js appear after 'bar'.
172172
expect(result).toMatch(
173173
// eslint-disable-next-line max-len
174-
/console.log\('bar'\);<\/script>\s*<script>\(\(\) => {\s*const scripts = \[\['.\/main3.js', '', false, false, null, null\],\['.\/main4.js', '', false, false, null, null\]\];/,
174+
/console.log\('bar'\);<\/script>\s*<script>\(\(\) => {\s*const scripts = \[\['.\/main3.js', "", false, false, null, null\],\['.\/main4.js', "", false, false, null, null\]\];/,
175175
);
176176
// Exactly 4 scripts should be left.
177177
expect(Array.from(result.matchAll(/<script>/gi)).length).toEqual(4);
@@ -238,7 +238,7 @@ describe('auto-csp', () => {
238238
expect(csps).toHaveSize(1);
239239
expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX);
240240
expect(result).toContain(
241-
`const scripts = [['./main.js', 'module', false, false, "sha384-xyz123", "anonymous"]];`,
241+
`const scripts = [['./main.js', "module", false, false, "sha384-xyz123", "anonymous"]];`,
242242
);
243243
});
244244

@@ -258,7 +258,7 @@ describe('auto-csp', () => {
258258
expect(csps).toHaveSize(1);
259259
expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX);
260260
expect(result).toContain(
261-
`const scripts = [['./main.js', '', false, false, "sha384-xyz123", null]];`,
261+
`const scripts = [['./main.js', "", false, false, "sha384-xyz123", null]];`,
262262
);
263263
});
264264

@@ -278,7 +278,45 @@ describe('auto-csp', () => {
278278
expect(csps).toHaveSize(1);
279279
expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX);
280280
expect(result).toContain(
281-
`const scripts = [['./main.js', '', false, false, null, "anonymous"]];`,
281+
`const scripts = [['./main.js', "", false, false, null, "anonymous"]];`,
282282
);
283283
});
284+
285+
it('should encode a script type that carries MIME parameters', async () => {
286+
const result = await autoCsp(`
287+
<html>
288+
<head>
289+
</head>
290+
<body>
291+
<script src="./main.js" type="text/javascript;']];var x=1;var junk=[['a','b"></script>
292+
</body>
293+
</html>
294+
`);
295+
296+
const csps = getCsps(result);
297+
expect(csps).toHaveSize(1);
298+
expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX);
299+
// The type stays inside its string literal.
300+
expect(result).toContain(
301+
`const scripts = [['./main.js', "text/javascript;']];var x=1;var junk=[['a','b", false, false, null, null]];`,
302+
);
303+
});
304+
305+
it('should encode a script type that contains a closing script tag', async () => {
306+
const result = await autoCsp(`
307+
<html>
308+
<head>
309+
</head>
310+
<body>
311+
<script src="./main.js" type="text/javascript;</script><script>x</script>"></script>
312+
</body>
313+
</html>
314+
`);
315+
316+
const csps = getCsps(result);
317+
expect(csps).toHaveSize(1);
318+
expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX);
319+
// Only the loader element is emitted.
320+
expect(Array.from(result.matchAll(/<script/gi)).length).toEqual(1);
321+
});
284322
});

0 commit comments

Comments
 (0)