From 0a227883237d6c822ca670dea67507f5d6993800 Mon Sep 17 00:00:00 2001 From: Aaron Shim Date: Tue, 15 Oct 2024 08:56:53 +0000 Subject: [PATCH] refactor(@angular/build): Auto-CSP support as an index file transformation. Auto-CSP is a feature to rewrite the ``); + } + scriptContent = undefined; + } + rewriter.emitStartTag(tag); + }); + + rewriter.on('text', (tag, html) => { + if (openedScriptTag && !getScriptAttributeValue(openedScriptTag, 'src')) { + hashes.push(hashInlineScript(html)); + } + rewriter.emitText(tag); + }); + + rewriter.on('endTag', (tag, html) => { + if (tag.tagName === 'script') { + const src = getScriptAttributeValue(openedScriptTag!, 'src'); + openedScriptTag = undefined; + + if (src) { + return; + } + } + + if (tag.tagName === 'body' || tag.tagName === 'html') { + // Write the loader script if a string of `); + } + scriptContent = undefined; + } + } + rewriter.emitEndTag(tag); + }); + + const rewritten = await transformedContent(); + + // Second pass to add the header + const secondPass = await htmlRewritingStream(rewritten); + secondPass.rewriter.on('endTag', (tag, _) => { + if (tag.tagName === 'head') { + // See what hashes we came up with! + secondPass.rewriter.emitRaw( + ``, + ); + } + secondPass.rewriter.emitEndTag(tag); + }); + return secondPass.transformedContent(); +} + +/** + * Returns a strict Content Security Policy for mittigating XSS. + * For more details read csp.withgoogle.com. + * If you modify this CSP, make sure it has not become trivially bypassable by + * checking the policy using csp-evaluator.withgoogle.com. + * + * @param hashes A list of sha-256 hashes of trusted inline scripts. + * @param enableTrustedTypes If Trusted Types should be enabled for scripts. + * @param enableBrowserFallbacks If fallbacks for older browsers should be + * added. This is will not weaken the policy as modern browsers will ignore + * the fallbacks. + * @param enableUnsafeEval If you cannot remove all uses of eval(), you can + * still set a strict CSP, but you will have to use the 'unsafe-eval' + * keyword which will make your policy slightly less secure. + */ +function getStrictCsp( + hashes?: string[], + // default CSP options + cspOptions: { + enableBrowserFallbacks?: boolean; + enableTrustedTypes?: boolean; + enableUnsafeEval?: boolean; + } = { + enableBrowserFallbacks: true, + enableTrustedTypes: false, + enableUnsafeEval: false, + }, +): string { + hashes = hashes || []; + let strictCspTemplate = { + // 'strict-dynamic' allows hashed scripts to create new scripts. + 'script-src': [`'strict-dynamic'`, ...hashes], + // Restricts `object-src` to disable dangerous plugins like Flash. + 'object-src': [`'none'`], + // Restricts `base-uri` to block the injection of `` tags. This + // prevents attackers from changing the locations of scripts loaded from + // relative URLs. + 'base-uri': [`'self'`], + }; + + // Adds fallbacks for browsers not compatible to CSP3 and CSP2. + // These fallbacks are ignored by modern browsers in presence of hashes, + // and 'strict-dynamic'. + if (cspOptions.enableBrowserFallbacks) { + // Fallback for Safari. All modern browsers supporting strict-dynamic will + // ignore the 'https:' fallback. + strictCspTemplate['script-src'].push('https:'); + // 'unsafe-inline' is only ignored in presence of a hash or nonce. + if (hashes.length > 0) { + strictCspTemplate['script-src'].push(`'unsafe-inline'`); + } + } + + // If enabled, dangerous DOM sinks will only accept typed objects instead of + // strings. + if (cspOptions.enableTrustedTypes) { + strictCspTemplate = { + ...strictCspTemplate, + ...{ 'require-trusted-types-for': [`'script'`] }, + }; + } + + // If enabled, `eval()`-calls will be allowed, making the policy slightly + // less secure. + if (cspOptions.enableUnsafeEval) { + strictCspTemplate['script-src'].push(`'unsafe-eval'`); + } + + return Object.entries(strictCspTemplate) + .map(([directive, values]) => { + return `${directive} ${values.join(' ')};`; + }) + .join(''); +} + +/** + * Returns JS code for dynamically loading sourced (external) scripts. + * @param srcList A list of paths for scripts that should be loaded. + */ +function createLoaderScript( + srcList: SrcScriptTag[], + enableTrustedTypes = false, +): string | undefined { + if (!srcList.length) { + return undefined; + } + const srcListFormatted = srcList + .map((s) => `['${s.src}',${s.type ? "'" + s.type + "'" : undefined}, ${s.async}]`) + .join(); + return enableTrustedTypes + ? ` + var scripts = [${srcListFormatted}]; + var policy = self.trustedTypes && self.trustedTypes.createPolicy ? + self.trustedTypes.createPolicy('angular#auto-csp', {createScriptURL: function(u) { + return scripts.includes(u) ? u : null; + }}) : { createScriptURL: function(u) { return u; } }; + scripts.forEach(function(scriptUrl) { + var s = document.createElement('script'); + s.src = policy.createScriptURL(scriptUrl[0]); + s.type = scriptUrl[1]; + s.async = !!scriptUrl[2]; + document.body.appendChild(s); + });\n` + : ` + var scripts = [${srcListFormatted}]; + scripts.forEach(function(scriptUrl) { + var s = document.createElement('script'); + s.src = scriptUrl[0]; + s.type = scriptUrl[1]; + s.async = !!scriptUrl[2]; + document.body.appendChild(s); + });\n`; +} 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 new file mode 100644 index 000000000000..48deda8f6082 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/auto-csp_spec.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { autoCsp } from './auto-csp'; + +describe('auto-csp', () => { + it('should rewrite a single inline script', async () => { + const result = await autoCsp(` + + + + + +
Some text
+ + + `); + + expect(result).toContain( + ``, + ); + }); + + it('should rewrite a single source script', async () => { + const result = await autoCsp(` + + + + + +
Some text
+ + + `); + + expect(result).toContain( + ``, + ); + expect(result).toContain(`var scripts = [['./main.js',undefined, false]];`); + }); + + it('should rewrite a single source script in place', async () => { + const result = await autoCsp(` + + + + +
Some text
+ + + + `); + + expect(result).toContain( + ``, + ); + // Our loader script appears after the HTML text content. + expect(result).toMatch( + /Some text<\/div>\s* + + +
Some text
+ + + `); + + expect(result).toContain( + ``, + ); + expect(result).toContain( + `var scripts = [['./main1.js',undefined, false],['./main2.js',undefined, false],['./main3.js','module', false]];`, + ); + }); + + it('should rewrite all script tags', async () => { + const result = await autoCsp(` + + + + + + + + + + +
Some text
+ + + `); + + expect(result).toContain( + ``, + ); + // Loader script for main.js and main2.js appear after 'foo' and before 'bar'. + expect(result).toMatch( + /console.log\('foo'\);<\/script>\s*