-
Notifications
You must be signed in to change notification settings - Fork 12k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor(@angular/build): Auto-CSP support as an index file transform…
…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
Showing
5 changed files
with
375 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
252 changes: 252 additions & 0 deletions
252
packages/angular/build/src/utils/index-file/auto-csp.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
119
packages/angular/build/src/utils/index-file/auto-csp_spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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\]\];/, | ||
); | ||
}); | ||
}); |
Oops, something went wrong.