Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -238,5 +238,21 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
.withContext('importmap must precede the first module script tag')
.toBeLessThan(moduleScriptIdx);
});

it(`preserves integrity and crossorigin in autoCsp loader when autoCsp is also enabled`, async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
subresourceIntegrity: true,
security: { autoCsp: true },
});

const { result } = await harness.executeOnce();
expect(result?.success).toBeTrue();

const indexHtml = harness.readFile('dist/browser/index.html');
expect(indexHtml).toMatch(
/const scripts = \[\[(?:'[^']+', 'module', false, false, 'sha384-[^']+', 'anonymous'(?:, )?)+\]\];/,
);
});
});
});
45 changes: 32 additions & 13 deletions packages/angular/build/src/utils/index-file/auto-csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ interface SrcScriptTag {
type?: string;
async: boolean;
defer: boolean;
integrity?: string;
crossOrigin?: string;
}

/**
Expand Down Expand Up @@ -106,11 +108,14 @@ export async function autoCsp(html: string, unsafeEval = false): Promise<string>
// If there are any interesting attributes, note them down.
const scriptType = getScriptAttributeValue(tag, 'type');
if (shouldDynamicallyLoadScriptTagBasedOnType(scriptType)) {
const crossOrigin = getScriptAttributeValue(tag, 'crossorigin');
scriptContent.push({
src: src,
type: scriptType,
async: getScriptAttributeValue(tag, 'async') !== undefined,
defer: getScriptAttributeValue(tag, 'defer') !== undefined,
integrity: getScriptAttributeValue(tag, 'integrity'),
crossOrigin: crossOrigin === '' ? 'anonymous' : crossOrigin,
});

return; // Skip writing my script tag until we've read it all.
Expand Down Expand Up @@ -267,36 +272,50 @@ function createLoaderScript(srcList: SrcScriptTag[], enableTrustedTypes = false)
const srcAttr = encodeURI(s.src).replaceAll("'", "\\'");
// Can only be 'module' or a JS MIME type or an empty string.
const typeAttr = s.type ? "'" + s.type + "'" : "''";
const asyncAttr = s.async ? 'true' : 'false';
const deferAttr = s.defer ? 'true' : 'false';
const asyncAttr = !!s.async;
const deferAttr = !!s.defer;
const integrityAttr = s.integrity ? "'" + s.integrity.replaceAll("'", "\\'") + "'" : null;
const crossOriginAttr = s.crossOrigin ? `'${s.crossOrigin.replaceAll("'", "\\'")}'` : null;
Comment on lines +277 to +278

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT:

Suggested change
const integrityAttr = s.integrity ? "'" + s.integrity.replaceAll("'", "\\'") + "'" : null;
const crossOriginAttr = s.crossOrigin ? `'${s.crossOrigin.replaceAll("'", "\\'")}'` : null;
const integrityAttr = JSON.stringify(s.integrity ?? null).replaceAll('<', '\\u003c');
const crossOriginAttr = JSON.stringify(s.crossOrigin ?? null).replaceAll('<', '\\u003c');


return `['${srcAttr}', ${typeAttr}, ${asyncAttr}, ${deferAttr}]`;
return `['${srcAttr}', ${typeAttr}, ${asyncAttr}, ${deferAttr}, ${integrityAttr}, ${crossOriginAttr}]`;
})
.join();

return enableTrustedTypes
? `
var scripts = [${srcListFormatted}];
var policy = self.trustedTypes && self.trustedTypes.createPolicy ?
const scripts = [${srcListFormatted}];
const policy = self.trustedTypes && self.trustedTypes.createPolicy ?

@clydin clydin Aug 13, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the switch to const, we should also wrap these script blocks in an IIFE to scope the variables. Since these are currently at the top-level, any other script element that happens to use the same names will cause an error.
The IIFE is also slightly better for performance since the values can be garbage collected after use.

self.trustedTypes.createPolicy('angular#auto-csp', {createScriptURL: function(u) {
return scripts.includes(u) ? u : null;
return scripts.some(function(s) { return s[0] === u; }) ? u : null;
}}) : { createScriptURL: function(u) { return u; } };
scripts.forEach(function(scriptUrl) {
var s = document.createElement('script');
const s = document.createElement('script');
s.src = policy.createScriptURL(scriptUrl[0]);
s.type = scriptUrl[1];
s.async = !!scriptUrl[2];
s.defer = !!scriptUrl[3];
s.async = scriptUrl[2];
s.defer = scriptUrl[3];
if (scriptUrl[4]) {
s.integrity = scriptUrl[4];
}
if (scriptUrl[5]) {
s.crossOrigin = scriptUrl[5];
}
document.lastElementChild.appendChild(s);
});\n`
: `
var scripts = [${srcListFormatted}];
const scripts = [${srcListFormatted}];
scripts.forEach(function(scriptUrl) {
var s = document.createElement('script');
const s = document.createElement('script');
s.src = scriptUrl[0];
s.type = scriptUrl[1];
s.async = !!scriptUrl[2];
s.defer = !!scriptUrl[3];
s.async = scriptUrl[2];
s.defer = scriptUrl[3];
if (scriptUrl[4]) {
s.integrity = scriptUrl[4];
}
if (scriptUrl[5]) {
s.crossOrigin = scriptUrl[5];
}
document.lastElementChild.appendChild(s);
});\n`;
}
70 changes: 65 additions & 5 deletions packages/angular/build/src/utils/index-file/auto-csp_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe('auto-csp', () => {
const csps = getCsps(result);
expect(csps).toHaveSize(1);
expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX);
expect(result).toContain(`var scripts = [['./main.js', '', false, false]];`);
expect(result).toContain(`const scripts = [['./main.js', '', false, false, null, null]];`);
});

it('should rewrite a single source script in place', async () => {
Expand All @@ -78,7 +78,7 @@ describe('auto-csp', () => {
expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX);
// Our loader script appears after the HTML text content.
expect(result).toMatch(
/Some text<\/div>\s*<script>\s*var scripts = \[\['.\/main.js', '', false, false\]\];/,
/Some text<\/div>\s*<script>\s*const scripts = \[\['.\/main.js', '', false, false, null, null\]\];/,
);
});

Expand All @@ -103,7 +103,7 @@ describe('auto-csp', () => {
expect(csps[0]).toMatch(CSP_TWO_HASHES_REGEX);
expect(result).toContain(
// eslint-disable-next-line max-len
`var scripts = [['./main1.js', '', false, false],['./main2.js', '', true, false],['./main3.js', 'module', true, true]];`,
`const scripts = [['./main1.js', '', false, false, null, null],['./main2.js', '', true, false, null, null],['./main3.js', 'module', true, true, null, null]];`,
);
// Head loader script is in the head.
expect(result).toContain(`</script></head>`);
Expand Down Expand Up @@ -166,12 +166,12 @@ describe('auto-csp', () => {
// Loader script for main.js and main2.js appear after 'foo' and before 'bar'.
expect(result).toMatch(
// eslint-disable-next-line max-len
/console.log\('foo'\);<\/script>\s*<script>\s*var scripts = \[\['.\/main.js', '', false, false\],\['.\/main2.js', '', false, false\]\];[\s\S]*console.log\('bar'\);/,
/console.log\('foo'\);<\/script>\s*<script>\s*const scripts = \[\['.\/main.js', '', false, false, null, null\],\['.\/main2.js', '', false, false, null, null\]\];[\s\S]*console.log\('bar'\);/,
);
// Loader script for main3.js and main4.js appear after 'bar'.
expect(result).toMatch(
// eslint-disable-next-line max-len
/console.log\('bar'\);<\/script>\s*<script>\s*var scripts = \[\['.\/main3.js', '', false, false\],\['.\/main4.js', '', false, false\]\];/,
/console.log\('bar'\);<\/script>\s*<script>\s*const scripts = \[\['.\/main3.js', '', false, false, null, null\],\['.\/main4.js', '', false, false, null, null\]\];/,
);
// Exactly 4 scripts should be left.
expect(Array.from(result.matchAll(/<script>/gi)).length).toEqual(4);
Expand Down Expand Up @@ -221,4 +221,64 @@ describe('auto-csp', () => {
expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX);
expect(csps[0]).toContain(hashTextContent(`\r\nconsole.log('foo');\r\n`));
});

it('should preserve integrity and crossorigin attributes in loader script', async () => {
const result = await autoCsp(`
<html>
<head>
</head>
<body>
<script src="./main.js" type="module" crossorigin="anonymous" integrity="sha384-xyz123"></script>
<div>Some text </div>
</body>
</html>
`);

const csps = getCsps(result);
expect(csps).toHaveSize(1);
expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX);
expect(result).toContain(
`const scripts = [['./main.js', 'module', false, false, 'sha384-xyz123', 'anonymous']];`,
);
});

it('should preserve only integrity attribute when crossorigin is omitted', async () => {
const result = await autoCsp(`
<html>
<head>
</head>
<body>
<script src="./main.js" integrity="sha384-xyz123"></script>
<div>Some text </div>
</body>
</html>
`);

const csps = getCsps(result);
expect(csps).toHaveSize(1);
expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX);
expect(result).toContain(
`const scripts = [['./main.js', '', false, false, 'sha384-xyz123', null]];`,
);
});

it('should map empty crossorigin attribute to anonymous', async () => {
const result = await autoCsp(`
<html>
<head>
</head>
<body>
<script src="./main.js" crossorigin></script>
<div>Some text </div>
</body>
</html>
`);

const csps = getCsps(result);
expect(csps).toHaveSize(1);
expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX);
expect(result).toContain(
`const scripts = [['./main.js', '', false, false, null, 'anonymous']];`,
);
});
});