Skip to content
Merged
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
37 changes: 33 additions & 4 deletions packages/angular/build/src/utils/index-file/auto-csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,40 @@ function getScriptAttributeValue(tag: StartTag, attrName: string): string | unde
return tag.attrs.find((attr) => attr.name === attrName)?.value;
}

/**
* All MIME types associated with JavaScript according to the HTML specification:
* https://html.spec.whatwg.org/multipage/scripting.html#javascript-mime-type
*/
const JAVASCRIPT_MIME_TYPES = new Set([
'application/ecmascript',
'application/javascript',
'application/x-ecmascript',
'application/x-javascript',
'text/ecmascript',
'text/javascript',
'text/javascript1.0',
'text/javascript1.1',
'text/javascript1.2',
'text/javascript1.3',
'text/javascript1.4',
'text/javascript1.5',
'text/jscript',
'text/livescript',
'text/x-ecmascript',
'text/x-javascript',
]);

/**
* Checks whether a particular string is a MIME type associated with JavaScript, according to
* https://developer.mozilla.org/en-US/docs/Web/HTTP/MIME_types#textjavascript
* https://html.spec.whatwg.org/multipage/scripting.html#javascript-mime-type
*
* @param mimeType a string that may be a MIME type
* @returns whether the string is a MIME type that is associated with JavaScript
*/
function isJavascriptMimeType(mimeType: string): boolean {
return mimeType.split(';')[0] === 'text/javascript';
export function isJavascriptMimeType(mimeType: string): boolean {
const [essence] = mimeType.split(';', 1);

return JAVASCRIPT_MIME_TYPES.has(essence.trim().toLowerCase());
}

/**
Expand All @@ -54,7 +79,11 @@ function isJavascriptMimeType(mimeType: string): boolean {
* @returns whether to add the script tag to the dynamically loaded script tag
*/
function shouldDynamicallyLoadScriptTagBasedOnType(scriptType: string | undefined): boolean {
return !scriptType || scriptType === 'module' || isJavascriptMimeType(scriptType);
if (!scriptType) {
return true;
}

return scriptType.trim().toLowerCase() === 'module' || isJavascriptMimeType(scriptType);
}

/**
Expand Down
119 changes: 118 additions & 1 deletion packages/angular/build/src/utils/index-file/auto-csp_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import { autoCsp, hashTextContent } from './auto-csp';
import { autoCsp, hashTextContent, isJavascriptMimeType } from './auto-csp';

// Utility function to grab the meta tag CSPs from the HTML response.
const getCsps = (html: string) => {
Expand Down Expand Up @@ -281,4 +281,121 @@ describe('auto-csp', () => {
`const scripts = [['./main.js', '', false, false, null, "anonymous"]];`,
);
});

it('should rewrite scripts with application/javascript type', async () => {
const result = await autoCsp(`
<html>
<head></head>
<body>
<script src="./main.js" type="application/javascript"></script>
</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', 'application/javascript', false, false, null, null]];`,
);
});

it('should rewrite scripts with case-insensitive type and parameters with whitespace', async () => {
const result = await autoCsp(`
<html>
<head></head>
<body>
<script src="./main.js" type="Text/JavaScript ; charset=utf-8"></script>
</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', 'Text/JavaScript ; charset=utf-8', false, false, null, null]];`,
);
});

it('should rewrite scripts with case-insensitive module type', async () => {
const result = await autoCsp(`
<html>
<head></head>
<body>
<script src="./main.js" type="Module"></script>
</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, null, null]];`,
);
});

it('should not rewrite non-JavaScript script tags', async () => {
const result = await autoCsp(`
<html>
<head></head>
<body>
<script src="./data.json" type="application/json"></script>
</body>
</html>
`);

// No dynamic loader script is emitted because application/json is not JavaScript.
expect(result).toContain('<script src="./data.json" type="application/json"></script>');
expect(result).not.toContain('const scripts =');
});

describe('isJavascriptMimeType', () => {
it('should identify standard JavaScript MIME types', () => {
expect(isJavascriptMimeType('text/javascript')).toBeTrue();
expect(isJavascriptMimeType('application/javascript')).toBeTrue();
expect(isJavascriptMimeType('application/x-javascript')).toBeTrue();
expect(isJavascriptMimeType('text/ecmascript')).toBeTrue();
expect(isJavascriptMimeType('application/ecmascript')).toBeTrue();
expect(isJavascriptMimeType('text/jscript')).toBeTrue();
expect(isJavascriptMimeType('text/livescript')).toBeTrue();
expect(isJavascriptMimeType('text/x-ecmascript')).toBeTrue();
expect(isJavascriptMimeType('text/x-javascript')).toBeTrue();
expect(isJavascriptMimeType('text/javascript1.5')).toBeTrue();
});

it('should ignore parameters when matching MIME type', () => {
expect(isJavascriptMimeType('text/javascript; charset=utf-8')).toBeTrue();
expect(isJavascriptMimeType('application/javascript;version=1.8')).toBeTrue();
});

it('should handle leading, trailing, and parameter whitespace', () => {
expect(isJavascriptMimeType(' text/javascript ')).toBeTrue();
expect(isJavascriptMimeType('text/javascript ; charset=utf-8')).toBeTrue();
expect(isJavascriptMimeType(' application/javascript ; version=1.0 ')).toBeTrue();
});

it('should be case-insensitive', () => {
expect(isJavascriptMimeType('Text/JavaScript')).toBeTrue();
expect(isJavascriptMimeType('APPLICATION/JAVASCRIPT')).toBeTrue();
expect(isJavascriptMimeType('text/JAVASCRIPT; charset=UTF-8')).toBeTrue();
});

it('should reject non-JavaScript MIME types', () => {
expect(isJavascriptMimeType('application/json')).toBeFalse();
expect(isJavascriptMimeType('text/html')).toBeFalse();
expect(isJavascriptMimeType('text/css')).toBeFalse();
expect(isJavascriptMimeType('image/svg+xml')).toBeFalse();
expect(isJavascriptMimeType('importmap')).toBeFalse();
expect(isJavascriptMimeType('module')).toBeFalse();
expect(isJavascriptMimeType('')).toBeFalse();
});

it('should reject invalid MIME types with whitespace inside the essence', () => {
expect(isJavascriptMimeType('text / javascript')).toBeFalse();
expect(isJavascriptMimeType('application / javascript')).toBeFalse();
expect(isJavascriptMimeType('text/java script')).toBeFalse();
});
});
});
Loading