Skip to content

Commit 4168106

Browse files
committed
refactor(@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 446fd94 commit 4168106

File tree

2 files changed

+469
-0
lines changed

2 files changed

+469
-0
lines changed
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
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 * as crypto from 'node:crypto';
10+
import { StartTag, htmlRewritingStream } from './html-rewriting-stream';
11+
12+
/**
13+
* The hash function to use for hash directives to use in the CSP.
14+
*/
15+
const HASH_FUNCTION = 'sha256';
16+
17+
/**
18+
* Store the appropriate attributes of a sourced script tag to generate the loader script.
19+
*/
20+
interface SrcScriptTag {
21+
src: string;
22+
type?: string;
23+
async: boolean;
24+
defer: boolean;
25+
}
26+
27+
/**
28+
* Get the specified attribute or return undefined if the tag doesn't have that attribute.
29+
*
30+
* @param tag StartTag of the <script>
31+
* @returns
32+
*/
33+
function getScriptAttributeValue(tag: StartTag, attrName: string): string | undefined {
34+
return tag.attrs.find((attr) => attr.name === attrName)?.value;
35+
}
36+
37+
/**
38+
* Checks whether a particular string is a MIME type associated with JavaScript, according to
39+
* https://developer.mozilla.org/en-US/docs/Web/HTTP/MIME_types#textjavascript
40+
*
41+
* @param mimeType a string that may be a MIME type
42+
* @returns whether the string is a MIME type that is associated with JavaScript
43+
*/
44+
function isJavascriptMimeType(mimeType: string): boolean {
45+
return mimeType.split(';')[0] === 'text/javascript';
46+
}
47+
48+
/**
49+
* Which of the type attributes on the script tag we should try passing along
50+
* based on https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type
51+
* @param scriptType the `type` attribute on the `<script>` tag under question
52+
* @returns whether to add the script tag to the dynamically loaded script tag
53+
*/
54+
function shouldDynamicallyLoadScriptTagBasedOnType(scriptType: string | undefined): boolean {
55+
return (
56+
scriptType === undefined ||
57+
scriptType === '' ||
58+
scriptType === 'module' ||
59+
isJavascriptMimeType(scriptType)
60+
);
61+
}
62+
63+
/**
64+
* Calculates a CSP compatible hash of an inline script.
65+
* @param scriptText Text between opening and closing script tag. Has to
66+
* include whitespaces and newlines!
67+
* @returns The hash of the text formatted appropriately for CSP.
68+
*/
69+
export function hashTextContent(scriptText: string): string {
70+
const hash = crypto.createHash(HASH_FUNCTION).update(scriptText, 'utf-8').digest('base64');
71+
72+
return `'${HASH_FUNCTION}-${hash}'`;
73+
}
74+
75+
/**
76+
* Finds all `<script>` tags and creates a dynamic script loading block for consecutive `<script>` with `src` attributes.
77+
* Hashes all scripts, both inline and generated dynamic script loading blocks.
78+
* Inserts a `<meta>` tag at the end of the `<head>` of the document with the generated hash-based CSP.
79+
*
80+
* @param html Markup that should be processed.
81+
* @returns The transformed HTML that contains the `<meta>` tag CSP and dynamic loader scripts.
82+
*/
83+
export async function autoCsp(html: string): Promise<string> {
84+
const { rewriter, transformedContent } = await htmlRewritingStream(html);
85+
86+
let openedScriptTag: StartTag | undefined = undefined;
87+
let scriptContent: SrcScriptTag[] = [];
88+
const hashes: string[] = [];
89+
90+
/**
91+
* Generates the dynamic loading script and puts it in the rewriter and adds the hash of the dynamic
92+
* loader script to the collection of hashes to add to the <meta> tag CSP.
93+
*/
94+
function emitLoaderScript() {
95+
const loaderScript = createLoaderScript(scriptContent);
96+
hashes.push(hashTextContent(loaderScript));
97+
rewriter.emitRaw(`<script>${loaderScript}</script>`);
98+
scriptContent = [];
99+
}
100+
101+
rewriter.on('startTag', (tag, html) => {
102+
if (tag.tagName === 'script') {
103+
openedScriptTag = tag;
104+
const src = getScriptAttributeValue(tag, 'src');
105+
106+
if (src) {
107+
// If there are any interesting attributes, note them down.
108+
const scriptType = getScriptAttributeValue(tag, 'type');
109+
if (shouldDynamicallyLoadScriptTagBasedOnType(scriptType)) {
110+
scriptContent.push({
111+
src: src,
112+
type: scriptType,
113+
async: getScriptAttributeValue(tag, 'async') !== undefined,
114+
defer: getScriptAttributeValue(tag, 'defer') !== undefined,
115+
});
116+
117+
return; // Skip writing my script tag until we've read it all.
118+
}
119+
}
120+
}
121+
// We are encountering the first start tag that's not <script src="..."> after a string of
122+
// consecutive <script src="...">. <script> tags without a src attribute will also end a chain
123+
// of src attributes that can be loaded in a single loader script, so those will end up here.
124+
//
125+
// The first place when we can determine this to be the case is
126+
// during the first opening tag that's not <script src="...">, where we need to insert the
127+
// dynamic loader script before continuing on with writing the rest of the tags.
128+
// (One edge case is where there are no more opening tags after the last <script src="..."> is
129+
// closed, but this case is handled below with the final </body> tag.)
130+
if (scriptContent.length > 0) {
131+
emitLoaderScript();
132+
}
133+
rewriter.emitStartTag(tag);
134+
});
135+
136+
rewriter.on('text', (tag, html) => {
137+
if (openedScriptTag && !getScriptAttributeValue(openedScriptTag, 'src')) {
138+
hashes.push(hashTextContent(html));
139+
}
140+
rewriter.emitText(tag);
141+
});
142+
143+
rewriter.on('endTag', (tag, html) => {
144+
if (openedScriptTag && tag.tagName === 'script') {
145+
const src = getScriptAttributeValue(openedScriptTag, 'src');
146+
const scriptType = getScriptAttributeValue(openedScriptTag, 'type');
147+
openedScriptTag = undefined;
148+
// Return early to avoid writing the closing </script> tag if it's a part of the
149+
// dynamic loader script.
150+
if (src && shouldDynamicallyLoadScriptTagBasedOnType(scriptType)) {
151+
return;
152+
}
153+
}
154+
155+
if (tag.tagName === 'body' || tag.tagName === 'html') {
156+
// Write the loader script if a string of <script>s were the last opening tag of the document.
157+
if (scriptContent.length > 0) {
158+
emitLoaderScript();
159+
}
160+
}
161+
rewriter.emitEndTag(tag);
162+
});
163+
164+
const rewritten = await transformedContent();
165+
166+
// Second pass to add the header
167+
const secondPass = await htmlRewritingStream(rewritten);
168+
secondPass.rewriter.on('startTag', (tag, _) => {
169+
secondPass.rewriter.emitStartTag(tag);
170+
if (tag.tagName === 'head') {
171+
// See what hashes we came up with!
172+
secondPass.rewriter.emitRaw(
173+
`<meta http-equiv="Content-Security-Policy" content="${getStrictCsp(hashes)}">`,
174+
);
175+
}
176+
});
177+
178+
return secondPass.transformedContent();
179+
}
180+
181+
/**
182+
* Returns a strict Content Security Policy for mitigating XSS.
183+
* For more details read csp.withgoogle.com.
184+
* If you modify this CSP, make sure it has not become trivially bypassable by
185+
* checking the policy using csp-evaluator.withgoogle.com.
186+
*
187+
* @param hashes A list of sha-256 hashes of trusted inline scripts.
188+
* @param enableTrustedTypes If Trusted Types should be enabled for scripts.
189+
* @param enableBrowserFallbacks If fallbacks for older browsers should be
190+
* added. This is will not weaken the policy as modern browsers will ignore
191+
* the fallbacks.
192+
* @param enableUnsafeEval If you cannot remove all uses of eval(), you can
193+
* still set a strict CSP, but you will have to use the 'unsafe-eval'
194+
* keyword which will make your policy slightly less secure.
195+
*/
196+
function getStrictCsp(
197+
hashes?: string[],
198+
// default CSP options
199+
cspOptions: {
200+
enableBrowserFallbacks?: boolean;
201+
enableTrustedTypes?: boolean;
202+
enableUnsafeEval?: boolean;
203+
} = {
204+
enableBrowserFallbacks: true,
205+
enableTrustedTypes: false,
206+
enableUnsafeEval: false,
207+
},
208+
): string {
209+
hashes = hashes || [];
210+
const strictCspTemplate: Record<string, string[]> = {
211+
// 'strict-dynamic' allows hashed scripts to create new scripts.
212+
'script-src': [`'strict-dynamic'`, ...hashes],
213+
// Restricts `object-src` to disable dangerous plugins like Flash.
214+
'object-src': [`'none'`],
215+
// Restricts `base-uri` to block the injection of `<base>` tags. This
216+
// prevents attackers from changing the locations of scripts loaded from
217+
// relative URLs.
218+
'base-uri': [`'self'`],
219+
};
220+
221+
// Adds fallbacks for browsers not compatible to CSP3 and CSP2.
222+
// These fallbacks are ignored by modern browsers in presence of hashes,
223+
// and 'strict-dynamic'.
224+
if (cspOptions.enableBrowserFallbacks) {
225+
// Fallback for Safari. All modern browsers supporting strict-dynamic will
226+
// ignore the 'https:' fallback.
227+
strictCspTemplate['script-src'].push('https:');
228+
// 'unsafe-inline' is only ignored in presence of a hash or nonce.
229+
if (hashes.length > 0) {
230+
strictCspTemplate['script-src'].push(`'unsafe-inline'`);
231+
}
232+
}
233+
234+
// If enabled, dangerous DOM sinks will only accept typed objects instead of
235+
// strings.
236+
if (cspOptions.enableTrustedTypes) {
237+
strictCspTemplate['require-trusted-types-for'] = ['script'];
238+
}
239+
240+
// If enabled, `eval()`-calls will be allowed, making the policy slightly
241+
// less secure.
242+
if (cspOptions.enableUnsafeEval) {
243+
strictCspTemplate['script-src'].push(`'unsafe-eval'`);
244+
}
245+
246+
return Object.entries(strictCspTemplate)
247+
.map(([directive, values]) => {
248+
return `${directive} ${values.join(' ')};`;
249+
})
250+
.join('');
251+
}
252+
253+
/**
254+
* Returns JS code for dynamically loading sourced (external) scripts.
255+
* @param srcList A list of paths for scripts that should be loaded.
256+
*/
257+
function createLoaderScript(srcList: SrcScriptTag[], enableTrustedTypes = false): string {
258+
if (!srcList.length) {
259+
throw new Error('Cannot create a loader script with no scripts to load.');
260+
}
261+
const srcListFormatted = srcList
262+
.map((s) => {
263+
// URI encoding means value can't escape string, JS, or HTML context.
264+
const srcAttr = encodeURI(s.src).replaceAll("'", "\\'");
265+
// Can only be 'module' or a JS MIME type or an empty string.
266+
const typeAttr = s.type ? "'" + s.type + "'" : undefined;
267+
const asyncAttr = s.async ? 'true' : 'false';
268+
const deferAttr = s.defer ? 'true' : 'false';
269+
270+
return `['${srcAttr}', ${typeAttr}, ${asyncAttr}, ${deferAttr}]`;
271+
})
272+
.join();
273+
274+
return enableTrustedTypes
275+
? `
276+
var scripts = [${srcListFormatted}];
277+
var policy = self.trustedTypes && self.trustedTypes.createPolicy ?
278+
self.trustedTypes.createPolicy('angular#auto-csp', {createScriptURL: function(u) {
279+
return scripts.includes(u) ? u : null;
280+
}}) : { createScriptURL: function(u) { return u; } };
281+
scripts.forEach(function(scriptUrl) {
282+
var s = document.createElement('script');
283+
s.src = policy.createScriptURL(scriptUrl[0]);
284+
s.type = scriptUrl[1];
285+
s.async = !!scriptUrl[2];
286+
s.defer = !!scriptUrl[3];
287+
document.body.appendChild(s);
288+
});\n`
289+
: `
290+
var scripts = [${srcListFormatted}];
291+
scripts.forEach(function(scriptUrl) {
292+
var s = document.createElement('script');
293+
s.src = scriptUrl[0];
294+
s.type = scriptUrl[1];
295+
s.async = !!scriptUrl[2];
296+
s.defer = !!scriptUrl[3];
297+
document.body.appendChild(s);
298+
});\n`;
299+
}

0 commit comments

Comments
 (0)