Skip to content

Commit

Permalink
refactor(@angular/build): Auto-CSP support as an index file transform…
Browse files Browse the repository at this point in the history
…ation.

Auto-CSP is a feature to rewrite the `<script>` tags in a index.html
file to either hash their contents or rewrite them as a dynamic loader
script that can be hashed. These hashes will be placed in a CSP inside a
`<meta>` tag inside the `<head>` of the document to ensure that the
scripts running on the page are those known during the compile-time of
the client-side rendered application.
  • Loading branch information
aaronshim committed Oct 16, 2024
1 parent 29855bf commit 0a22788
Show file tree
Hide file tree
Showing 5 changed files with 375 additions and 1 deletion.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@
"ora": "5.4.1",
"pacote": "19.0.0",
"parse5-html-rewriting-stream": "7.0.0",
"parse5-sax-parser": "7.0.0",
"picomatch": "4.0.2",
"piscina": "4.7.0",
"postcss": "8.4.47",
Expand Down
1 change: 1 addition & 0 deletions packages/angular/build/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ ts_library(
"@npm//magic-string",
"@npm//mrmime",
"@npm//parse5-html-rewriting-stream",
"@npm//parse5-sax-parser",
"@npm//picomatch",
"@npm//piscina",
"@npm//postcss",
Expand Down
252 changes: 252 additions & 0 deletions packages/angular/build/src/utils/index-file/auto-csp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
/**
* @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 { htmlRewritingStream } from './html-rewriting-stream';
import { StartTag } from 'parse5-sax-parser';
import * as crypto from 'crypto';

/**
* The hash function to use for hash directives to use in the CSP.
*/
const HASH_FUNCTION = 'sha256';

/**
* Store the appropriate attributes of a sourced script tag to generate the loader script.
*/
interface SrcScriptTag {
scriptType: 'src';
src: string;
type?: string;
async?: boolean;
}

/**
* Get the specified attribute or return undefined if the tag doesn't have that attribute.
*
* @param tag StartTag of the <script>
* @returns
*/
function getScriptAttributeValue(tag: StartTag, attrName: string): string | undefined {
return tag.attrs.find((attr) => attr.name === attrName)?.value;
}

/**
* Calculates a CSP compatible hash of an inline script.
* @param scriptText Text between opening and closing script tag. Has to
* include whitespaces and newlines!
*/
function hashInlineScript(scriptText: string): string {
const hash = crypto.createHash(HASH_FUNCTION).update(scriptText, 'utf-8').digest('base64');
return `'${HASH_FUNCTION}-${hash}'`;
}

/**
* Finds all `<script>` tags and creates a dynamic script loading block for consecutive `<script>` with `src` attributes.
* Hashes all scripts, both inline and generated dynamic script loading blocks.
* Inserts a `<meta>` tag at the end of the `<head>` of the document with the generated hash-based CSP.
*
* @param html Markup that should be processed.
*/
export async function autoCsp(html: string): Promise<string> {
const { rewriter, transformedContent } = await htmlRewritingStream(html);

let openedScriptTag: StartTag | undefined = undefined;
let scriptContent: SrcScriptTag[] | undefined = undefined;
let hashes: string[] = [];

rewriter.on('startTag', (tag, html) => {
if (tag.tagName === 'script') {
openedScriptTag = tag;
const src = getScriptAttributeValue(tag, 'src');

if (src) {
// If there are any interesting attributes, note them down...
if (!scriptContent) {
scriptContent = [];
}
scriptContent.push({
scriptType: 'src',
src: src,
type: getScriptAttributeValue(tag, 'type'),
async: !!getScriptAttributeValue(tag, 'async'),
});
return; // Skip writing my script tag until we've read it all...
}
}
// We are encountering the first start tag that's not <script src="..."> after a string of those.
if (scriptContent) {
const loaderScript = createLoaderScript(scriptContent);
if (loaderScript) {
hashes.push(hashInlineScript(loaderScript));
rewriter.emitRaw(`<script>${loaderScript}</script>`);
}
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 <script>s were the last opening tag of the document.
if (scriptContent) {
const loaderScript = createLoaderScript(scriptContent);
if (loaderScript) {
hashes.push(hashInlineScript(loaderScript));
rewriter.emitRaw(`<script>${loaderScript}</script>`);
}
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(
`<meta http-equiv="Content-Security-Policy" content="${getStrictCsp(hashes)}">`,
);
}
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 `<base>` 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`;
}
119 changes: 119 additions & 0 deletions packages/angular/build/src/utils/index-file/auto-csp_spec.ts
Original file line number Diff line number Diff line change
@@ -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(`
<html>
<head>
</head>
<body>
<script>console.log('foo');</script>
<div>Some text </div>
</body>
</html>
`);

expect(result).toContain(
`<meta http-equiv="Content-Security-Policy" content="script-src 'strict-dynamic' 'sha256-1kOLrDKT3TBiHLcnxiGsc7HF/lyVJKLhoZDSn0UwCfo=' https: 'unsafe-inline';object-src 'none';base-uri 'self';">`,
);
});

it('should rewrite a single source script', async () => {
const result = await autoCsp(`
<html>
<head>
</head>
<body>
<script src="./main.js"></script>
<div>Some text </div>
</body>
</html>
`);

expect(result).toContain(
`<meta http-equiv="Content-Security-Policy" content="script-src 'strict-dynamic' 'sha256-lo8BxmGTvJc91EDqVJtKZxnRIqW9+qxQjfaPHuteg74=' https: 'unsafe-inline';object-src 'none';base-uri 'self';">`,
);
expect(result).toContain(`var scripts = [['./main.js',undefined, false]];`);
});

it('should rewrite a single source script in place', async () => {
const result = await autoCsp(`
<html>
<head>
</head>
<body>
<div>Some text</div>
<script src="./main.js"></script>
</body>
</html>
`);

expect(result).toContain(
`<meta http-equiv="Content-Security-Policy" content="script-src 'strict-dynamic' 'sha256-lo8BxmGTvJc91EDqVJtKZxnRIqW9+qxQjfaPHuteg74=' https: 'unsafe-inline';object-src 'none';base-uri 'self';">`,
);
// Our loader script appears after the HTML text content.
expect(result).toMatch(
/Some text<\/div>\s*<script>\s*var scripts = \[\['.\/main.js',undefined, false\]\];/,
);
});

it('should rewrite a multiple source scripts with attributes', async () => {
const result = await autoCsp(`
<html>
<head>
</head>
<body>
<script src="./main1.js"></script>
<script async src="./main2.js"></script>
<script type="module" async src="./main3.js"></script>
<div>Some text </div>
</body>
</html>
`);

expect(result).toContain(
`<meta http-equiv="Content-Security-Policy" content="script-src 'strict-dynamic' 'sha256-vkcRYaaRLTkuunplxAyjuivyXpK+pbbEfJB92l8u+aY=' https: 'unsafe-inline';object-src 'none';base-uri 'self';">`,
);
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(`
<html>
<head>
</head>
<body>
<script>console.log('foo');</script>
<script src="./main.js"></script>
<script src="./main2.js"></script>
<script>console.log('bar');</script>
<script src="./main3.js"></script>
<script src="./main4.js"></script>
<div>Some text </div>
</body>
</html>
`);

expect(result).toContain(
`<meta http-equiv="Content-Security-Policy" content="script-src 'strict-dynamic' 'sha256-1kOLrDKT3TBiHLcnxiGsc7HF/lyVJKLhoZDSn0UwCfo=' 'sha256-aYeGHKvB7drvnvtoSeU5AlgrkmC/pt0ltH/TfUHn2dE=' 'sha256-x9deMk4TZyx4r1lTUqvpVPW4DBzms/ehxbCInOrA8JM=' 'sha256-j3XWUuk9Y/6Ynlr4YmsBedweqkUK2wClLk2sd9gD6Tw=' https: 'unsafe-inline';object-src 'none';base-uri 'self';">`,
);
// Loader script for main.js and main2.js appear after 'foo' and before 'bar'.
expect(result).toMatch(
/console.log\('foo'\);<\/script>\s*<script>\s*var scripts = \[\['.\/main.js',undefined, false\],\['.\/main2.js',undefined, false\]\];[\s\S]*console.log\('bar'\);/,
);
// Loader script for main3.js and main4.js appear after 'bar'.
expect(result).toMatch(
/console.log\('bar'\);<\/script>\s*<script>\s*var scripts = \[\['.\/main3.js',undefined, false\],\['.\/main4.js',undefined, false\]\];/,
);
});
});
Loading

0 comments on commit 0a22788

Please sign in to comment.