Skip to content

Commit b23c240

Browse files
committed
feat(@angular/build): Auto-CSP support as an index file transformation.
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.
1 parent 29855bf commit b23c240

File tree

5 files changed

+376
-1
lines changed

5 files changed

+376
-1
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@
174174
"ora": "5.4.1",
175175
"pacote": "19.0.0",
176176
"parse5-html-rewriting-stream": "7.0.0",
177+
"parse5-sax-parser": "7.0.0",
177178
"picomatch": "4.0.2",
178179
"piscina": "4.7.0",
179180
"postcss": "8.4.47",

packages/angular/build/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ ts_library(
8989
"@npm//magic-string",
9090
"@npm//mrmime",
9191
"@npm//parse5-html-rewriting-stream",
92+
"@npm//parse5-sax-parser",
9293
"@npm//picomatch",
9394
"@npm//piscina",
9495
"@npm//postcss",
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { htmlRewritingStream } from './html-rewriting-stream';
10+
import { StartTag } from 'parse5-sax-parser';
11+
import * as crypto from 'crypto';
12+
13+
/**
14+
* The hash function to use for hash directives to use in the CSP.
15+
*/
16+
const HASH_FUNCTION = 'sha256';
17+
18+
/**
19+
* Store the appropriate attributes of a sourced script tag to generate the loader script.
20+
*/
21+
interface SrcScriptTag {
22+
scriptType: 'src';
23+
src: string;
24+
type?: string;
25+
async?: boolean;
26+
}
27+
28+
/**
29+
* Get the specified attribute or return undefined if the tag doesn't have that attribute.
30+
*
31+
* @param tag StartTag of the <script>
32+
* @returns
33+
*/
34+
function getScriptAttributeValue(tag: StartTag, attrName: string): string | undefined {
35+
return tag.attrs.find((attr) => attr.name === attrName)?.value;
36+
}
37+
38+
/**
39+
* Calculates a CSP compatible hash of an inline script.
40+
* @param scriptText Text between opening and closing script tag. Has to
41+
* include whitespaces and newlines!
42+
*/
43+
function hashInlineScript(scriptText: string): string {
44+
const hash = crypto.createHash(HASH_FUNCTION).update(scriptText, 'utf-8').digest('base64');
45+
return `'${HASH_FUNCTION}-${hash}'`;
46+
}
47+
48+
/**
49+
* Finds all `<script>` tags and creates a dynamic script loading block for consecutive `<script>` with `src` attributes.
50+
* Hashes all scripts, both inline and generated dynamic script loading blocks.
51+
* Inserts a `<meta>` tag at the end of the `<head>` of the document with the generated hash-based CSP.
52+
*
53+
* @param html Markup that should be processed.
54+
*/
55+
export async function autoCsp(html: string): Promise<string> {
56+
const { rewriter, transformedContent } = await htmlRewritingStream(html);
57+
58+
var openedScriptTag: StartTag | undefined = undefined;
59+
var scriptContent: SrcScriptTag[] | undefined = undefined;
60+
var hashes: string[] = [];
61+
62+
rewriter.on('startTag', (tag, html) => {
63+
if (tag.tagName === 'script') {
64+
openedScriptTag = tag;
65+
const src = getScriptAttributeValue(tag, 'src');
66+
67+
if (src) {
68+
// If there are any interesting attributes, note them down...
69+
if (!scriptContent) {
70+
scriptContent = [];
71+
}
72+
scriptContent.push({
73+
scriptType: 'src',
74+
src: src,
75+
type: getScriptAttributeValue(tag, 'type'),
76+
async: !!getScriptAttributeValue(tag, 'async'),
77+
});
78+
return; // Skip writing my script tag until we've read it all...
79+
}
80+
}
81+
// We are encountering the first start tag that's not <script src="..."> after a string of those.
82+
if (scriptContent) {
83+
const loaderScript = createLoaderScript(scriptContent);
84+
if (loaderScript) {
85+
hashes.push(hashInlineScript(loaderScript));
86+
rewriter.emitRaw(`<script>${loaderScript}</script>`);
87+
}
88+
scriptContent = undefined;
89+
}
90+
rewriter.emitStartTag(tag);
91+
});
92+
93+
rewriter.on('text', (tag, html) => {
94+
if (openedScriptTag && !getScriptAttributeValue(openedScriptTag, 'src')) {
95+
hashes.push(hashInlineScript(html));
96+
}
97+
rewriter.emitText(tag);
98+
});
99+
100+
rewriter.on('endTag', (tag, html) => {
101+
if (tag.tagName === 'script') {
102+
const src = getScriptAttributeValue(openedScriptTag!, 'src');
103+
openedScriptTag = undefined;
104+
105+
if (src) {
106+
return;
107+
}
108+
}
109+
110+
if (tag.tagName === 'body' || tag.tagName === 'html') {
111+
// Write the loader script if a string of <script>s were the last opening tag of the document.
112+
if (scriptContent) {
113+
const loaderScript = createLoaderScript(scriptContent);
114+
if (loaderScript) {
115+
hashes.push(hashInlineScript(loaderScript));
116+
rewriter.emitRaw(`<script>${loaderScript}</script>`);
117+
}
118+
scriptContent = undefined;
119+
}
120+
}
121+
rewriter.emitEndTag(tag);
122+
});
123+
124+
const rewritten = await transformedContent();
125+
126+
// Second pass to add the header
127+
const secondPass = await htmlRewritingStream(rewritten);
128+
secondPass.rewriter.on('endTag', (tag, _) => {
129+
if (tag.tagName === 'head') {
130+
// See what hashes we came up with!
131+
secondPass.rewriter.emitRaw(
132+
`<meta http-equiv="Content-Security-Policy" content="${getStrictCsp(hashes)}">`,
133+
);
134+
}
135+
secondPass.rewriter.emitEndTag(tag);
136+
});
137+
return secondPass.transformedContent();
138+
}
139+
140+
/**
141+
* Returns a strict Content Security Policy for mittigating XSS.
142+
* For more details read csp.withgoogle.com.
143+
* If you modify this CSP, make sure it has not become trivially bypassable by
144+
* checking the policy using csp-evaluator.withgoogle.com.
145+
*
146+
* @param hashes A list of sha-256 hashes of trusted inline scripts.
147+
* @param enableTrustedTypes If Trusted Types should be enabled for scripts.
148+
* @param enableBrowserFallbacks If fallbacks for older browsers should be
149+
* added. This is will not weaken the policy as modern browsers will ignore
150+
* the fallbacks.
151+
* @param enableUnsafeEval If you cannot remove all uses of eval(), you can
152+
* still set a strict CSP, but you will have to use the 'unsafe-eval'
153+
* keyword which will make your policy slightly less secure.
154+
*/
155+
function getStrictCsp(
156+
hashes?: string[],
157+
// default CSP options
158+
cspOptions: {
159+
enableBrowserFallbacks?: boolean;
160+
enableTrustedTypes?: boolean;
161+
enableUnsafeEval?: boolean;
162+
} = {
163+
enableBrowserFallbacks: true,
164+
enableTrustedTypes: false,
165+
enableUnsafeEval: false,
166+
},
167+
): string {
168+
hashes = hashes || [];
169+
let strictCspTemplate = {
170+
// 'strict-dynamic' allows hashed scripts to create new scripts.
171+
'script-src': [`'strict-dynamic'`, ...hashes],
172+
// Restricts `object-src` to disable dangerous plugins like Flash.
173+
'object-src': [`'none'`],
174+
// Restricts `base-uri` to block the injection of `<base>` tags. This
175+
// prevents attackers from changing the locations of scripts loaded from
176+
// relative URLs.
177+
'base-uri': [`'self'`],
178+
};
179+
180+
// Adds fallbacks for browsers not compatible to CSP3 and CSP2.
181+
// These fallbacks are ignored by modern browsers in presence of hashes,
182+
// and 'strict-dynamic'.
183+
if (cspOptions.enableBrowserFallbacks) {
184+
// Fallback for Safari. All modern browsers supporting strict-dynamic will
185+
// ignore the 'https:' fallback.
186+
strictCspTemplate['script-src'].push('https:');
187+
// 'unsafe-inline' is only ignored in presence of a hash or nonce.
188+
if (hashes.length > 0) {
189+
strictCspTemplate['script-src'].push(`'unsafe-inline'`);
190+
}
191+
}
192+
193+
// If enabled, dangerous DOM sinks will only accept typed objects instead of
194+
// strings.
195+
if (cspOptions.enableTrustedTypes) {
196+
strictCspTemplate = {
197+
...strictCspTemplate,
198+
...{ 'require-trusted-types-for': [`'script'`] },
199+
};
200+
}
201+
202+
// If enabled, `eval()`-calls will be allowed, making the policy slightly
203+
// less secure.
204+
if (cspOptions.enableUnsafeEval) {
205+
strictCspTemplate['script-src'].push(`'unsafe-eval'`);
206+
}
207+
208+
return Object.entries(strictCspTemplate)
209+
.map(([directive, values]) => {
210+
return `${directive} ${values.join(' ')};`;
211+
})
212+
.join('');
213+
}
214+
215+
/**
216+
* Returns JS code for dynamically loading sourced (external) scripts.
217+
* @param srcList A list of paths for scripts that should be loaded.
218+
*/
219+
function createLoaderScript(
220+
srcList: SrcScriptTag[],
221+
enableTrustedTypes = false,
222+
): string | undefined {
223+
if (!srcList.length) {
224+
return undefined;
225+
}
226+
const srcListFormatted = srcList
227+
.map((s) => `['${s.src}',${s.type ? "'" + s.type + "'" : undefined}, ${s.async}]`)
228+
.join();
229+
return enableTrustedTypes
230+
? `
231+
var scripts = [${srcListFormatted}];
232+
var policy = self.trustedTypes && self.trustedTypes.createPolicy ?
233+
self.trustedTypes.createPolicy('angular#auto-csp', {createScriptURL: function(u) {
234+
return scripts.includes(u) ? u : null;
235+
}}) : { createScriptURL: function(u) { return u; } };
236+
scripts.forEach(function(scriptUrl) {
237+
var s = document.createElement('script');
238+
s.src = policy.createScriptURL(scriptUrl[0]);
239+
s.type = scriptUrl[1];
240+
s.async = !!scriptUrl[2];
241+
document.body.appendChild(s);
242+
});\n`
243+
: `
244+
var scripts = [${srcListFormatted}];
245+
scripts.forEach(function(scriptUrl) {
246+
var s = document.createElement('script');
247+
s.src = scriptUrl[0];
248+
s.type = scriptUrl[1];
249+
s.async = !!scriptUrl[2];
250+
document.body.appendChild(s);
251+
});\n`;
252+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { autoCsp } from './auto-csp';
10+
11+
describe('auto-csp', () => {
12+
it('should rewrite a single inline script', async () => {
13+
const result = await autoCsp(`
14+
<html>
15+
<head>
16+
</head>
17+
<body>
18+
<script>console.log('foo');</script>
19+
<div>Some text </div>
20+
</body>
21+
</html>
22+
`);
23+
24+
expect(result).toContain(
25+
`<meta http-equiv="Content-Security-Policy" content="script-src 'strict-dynamic' 'sha256-1kOLrDKT3TBiHLcnxiGsc7HF/lyVJKLhoZDSn0UwCfo=' https: 'unsafe-inline';object-src 'none';base-uri 'self';">`,
26+
);
27+
});
28+
29+
it('should rewrite a single source script', async () => {
30+
const result = await autoCsp(`
31+
<html>
32+
<head>
33+
</head>
34+
<body>
35+
<script src="./main.js"></script>
36+
<div>Some text </div>
37+
</body>
38+
</html>
39+
`);
40+
41+
expect(result).toContain(
42+
`<meta http-equiv="Content-Security-Policy" content="script-src 'strict-dynamic' 'sha256-lo8BxmGTvJc91EDqVJtKZxnRIqW9+qxQjfaPHuteg74=' https: 'unsafe-inline';object-src 'none';base-uri 'self';">`,
43+
);
44+
expect(result).toContain(`var scripts = [['./main.js',undefined, false]];`);
45+
});
46+
47+
it('should rewrite a single source script in place', async () => {
48+
const result = await autoCsp(`
49+
<html>
50+
<head>
51+
</head>
52+
<body>
53+
<div>Some text</div>
54+
<script src="./main.js"></script>
55+
</body>
56+
</html>
57+
`);
58+
59+
expect(result).toContain(
60+
`<meta http-equiv="Content-Security-Policy" content="script-src 'strict-dynamic' 'sha256-lo8BxmGTvJc91EDqVJtKZxnRIqW9+qxQjfaPHuteg74=' https: 'unsafe-inline';object-src 'none';base-uri 'self';">`,
61+
);
62+
// Our loader script appears after the HTML text content.
63+
expect(result).toMatch(
64+
/Some text<\/div>\s*<script>\s*var scripts = \[\['.\/main.js',undefined, false\]\];/,
65+
);
66+
});
67+
68+
it('should rewrite a multiple source scripts with attributes', async () => {
69+
const result = await autoCsp(`
70+
<html>
71+
<head>
72+
</head>
73+
<body>
74+
<script src="./main1.js"></script>
75+
<script async src="./main2.js"></script>
76+
<script type="module" async src="./main3.js"></script>
77+
<div>Some text </div>
78+
</body>
79+
</html>
80+
`);
81+
82+
expect(result).toContain(
83+
`<meta http-equiv="Content-Security-Policy" content="script-src 'strict-dynamic' 'sha256-vkcRYaaRLTkuunplxAyjuivyXpK+pbbEfJB92l8u+aY=' https: 'unsafe-inline';object-src 'none';base-uri 'self';">`,
84+
);
85+
expect(result).toContain(
86+
`var scripts = [['./main1.js',undefined, false],['./main2.js',undefined, false],['./main3.js','module', false]];`,
87+
);
88+
});
89+
90+
it('should rewrite all script tags', async () => {
91+
debugger;
92+
const result = await autoCsp(`
93+
<html>
94+
<head>
95+
</head>
96+
<body>
97+
<script>console.log('foo');</script>
98+
<script src="./main.js"></script>
99+
<script src="./main2.js"></script>
100+
<script>console.log('bar');</script>
101+
<script src="./main3.js"></script>
102+
<script src="./main4.js"></script>
103+
<div>Some text </div>
104+
</body>
105+
</html>
106+
`);
107+
108+
expect(result).toContain(
109+
`<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';">`,
110+
);
111+
// Loader script for main.js and main2.js appear after 'foo' and before 'bar'.
112+
expect(result).toMatch(
113+
/console.log\('foo'\);<\/script>\s*<script>\s*var scripts = \[\['.\/main.js',undefined, false\],\['.\/main2.js',undefined, false\]\];[\s\S]*console.log\('bar'\);/,
114+
);
115+
// Loader script for main3.js and main4.js appear after 'bar'.
116+
expect(result).toMatch(
117+
/console.log\('bar'\);<\/script>\s*<script>\s*var scripts = \[\['.\/main3.js',undefined, false\],\['.\/main4.js',undefined, false\]\];/,
118+
);
119+
});
120+
});

0 commit comments

Comments
 (0)