From 9cb46886bc388a6e5a579651c3cbbb3974bc6f1e Mon Sep 17 00:00:00 2001 From: yoggydev <280342032+yoggydev@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:49:39 +0900 Subject: [PATCH] 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; 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. --- .../build/src/utils/index-file/auto-csp.ts | 6 ++- .../src/utils/index-file/auto-csp_spec.ts | 54 ++++++++++++++++--- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/angular/build/src/utils/index-file/auto-csp.ts b/packages/angular/build/src/utils/index-file/auto-csp.ts index df33f0766607..cc052a66bf06 100644 --- a/packages/angular/build/src/utils/index-file/auto-csp.ts +++ b/packages/angular/build/src/utils/index-file/auto-csp.ts @@ -270,8 +270,10 @@ function createLoaderScript(srcList: SrcScriptTag[], enableTrustedTypes = false) .map((s) => { // URI encoding means value can't escape string, JS, or HTML context. const srcAttr = encodeURI(s.src).replaceAll("'", "\\'"); - // Can only be 'module' or a JS MIME type or an empty string. - const typeAttr = s.type ? "'" + s.type + "'" : "''"; + // 'module', a JS MIME type, or an empty string. A JS MIME type may carry + // parameters after a ';', which isJavascriptMimeType() does not constrain, + // so encode this the same way as integrity and crossOrigin below. + const typeAttr = JSON.stringify(s.type ?? '').replaceAll('<', '\\u003c'); const asyncAttr = !!s.async; const deferAttr = !!s.defer; const integrityAttr = JSON.stringify(s.integrity ?? null).replaceAll('<', '\\u003c'); diff --git a/packages/angular/build/src/utils/index-file/auto-csp_spec.ts b/packages/angular/build/src/utils/index-file/auto-csp_spec.ts index 29e9bff68074..333a78294567 100644 --- a/packages/angular/build/src/utils/index-file/auto-csp_spec.ts +++ b/packages/angular/build/src/utils/index-file/auto-csp_spec.ts @@ -58,7 +58,7 @@ describe('auto-csp', () => { const csps = getCsps(result); expect(csps).toHaveSize(1); expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); - expect(result).toContain(`const scripts = [['./main.js', '', false, false, null, null]];`); + expect(result).toContain(`const scripts = [['./main.js', "", false, false, null, null]];`); }); it('should rewrite a single source script in place', async () => { @@ -78,7 +78,7 @@ describe('auto-csp', () => { expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); // Our loader script appears after the HTML text content. expect(result).toMatch( - /Some text<\/div>\s*`); @@ -166,12 +166,12 @@ describe('auto-csp', () => { // Loader script for main.js and main2.js appear after 'foo' and before 'bar'. expect(result).toMatch( // eslint-disable-next-line max-len - /console.log\('foo'\);<\/script>\s* + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + // The type stays inside its string literal. + expect(result).toContain( + `const scripts = [['./main.js', "text/javascript;']];var x=1;var junk=[['a','b", false, false, null, null]];`, + ); + }); + + it('should encode a script type that contains a closing script tag', async () => { + const result = await autoCsp(` + + + + + + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + // Only the loader element is emitted. + expect(Array.from(result.matchAll(/