diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts index 1ee430a76333..06e645badf71 100644 --- a/packages/angular/build/src/utils/server-rendering/manifest.ts +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -15,6 +15,7 @@ import { BuildOutputFileType, createOutputFile, } from '../../tools/esbuild/bundler-files'; +import { calculateHash } from '../hash'; export const SERVER_APP_MANIFEST_FILENAME = 'angular-app-manifest.mjs'; export const SERVER_APP_ENGINE_MANIFEST_FILENAME = 'angular-app-engine-manifest.mjs'; @@ -60,6 +61,37 @@ function escapeUnsafeChars(str: string): string { return str.replace(/[$`\\]/g, (c) => UNSAFE_CHAR_MAP[c]); } +/** + * Matches every character which is not safe in the name of a generated server asset chunk. + */ +const UNSAFE_CHUNK_NAME_CHARACTER_REGEXP = /[^a-zA-Z0-9_-]/g; + +/** + * The maximum number of characters of an asset path kept in the name of its generated chunk. + * The appended digest is what makes the name unique, so the readable part can be truncated to + * stay well within the file name length limits of all supported platforms. + */ +const MAX_CHUNK_NAME_LENGTH = 128; + +/** + * Builds the path of the generated chunk which holds the content of a server asset. + * + * Asset paths are derived from route paths and can therefore contain characters which are unusable + * in a file name (`?`, `:` and `*` are invalid on Windows) or which change how the generated + * dynamic import is resolved (`?`, `#` and `%` are URL syntax). Those characters are replaced, and + * a digest of the asset path is appended so that two asset paths never share a chunk. + * + * @param assetPath - The path of the asset, for example `store/summer sale/index.html`. + * @returns The path of the chunk to generate for the asset. + */ +function generateServerAssetChunkPath(assetPath: string): string { + const name = assetPath + .replace(UNSAFE_CHUNK_NAME_CHARACTER_REGEXP, '_') + .slice(0, MAX_CHUNK_NAME_LENGTH); + + return `assets-chunks/${name}-${calculateHash(assetPath)}.mjs`; +} + /** * Generates the server manifest for the App Engine environment. * @@ -85,7 +117,7 @@ export function generateAngularServerAppEngineManifest( for (const locale of i18nOptions.inlineLocales) { const { subPath } = i18nOptions.locales[locale]; const importPath = `${subPath ? `${subPath}/` : ''}${MAIN_SERVER_OUTPUT_FILENAME}`; - entryPoints[subPath] = `() => import('./${importPath}')`; + entryPoints[subPath] = `() => import(${JSON.stringify(`./${importPath}`)})`; supportedLocales[locale] = subPath; } } else { @@ -101,12 +133,12 @@ export function generateAngularServerAppEngineManifest( const manifestContent = ` export default { - basePath: '${basePath}', + basePath: ${JSON.stringify(basePath)}, allowedHosts: ${JSON.stringify(allowedHosts, undefined, 2)}, supportedLocales: ${JSON.stringify(supportedLocales, undefined, 2)}, entryPoints: { ${Object.entries(entryPoints) - .map(([key, value]) => `'${key}': ${value}`) + .map(([key, value]) => `${JSON.stringify(key)}: ${value}`) .join(',\n ')} }, }; @@ -163,7 +195,7 @@ export function generateAngularServerAppManifest( for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) { const extension = extname(file.path); if (extension === '.html' || (inlineCriticalCss && extension === '.css')) { - const jsChunkFilePath = `assets-chunks/${file.path.replace(/[./]/g, '_')}.mjs`; + const jsChunkFilePath = generateServerAssetChunkPath(file.path); const escapedContent = escapeUnsafeChars(file.text); serverAssetsChunks.push( @@ -183,8 +215,11 @@ export function generateAngularServerAppManifest( pos = file.text.indexOf('\r\n', pos + 2); } + // Asset paths are derived from route paths and can contain arbitrary characters, so they are + // serialized rather than interpolated into the generated executable manifest. serverAssets[file.path] = - `{size: ${size}, hash: '${file.hash}', text: () => import('./${jsChunkFilePath}').then(m => m.default)}`; + `{size: ${size}, hash: ${JSON.stringify(file.hash)}, ` + + `text: () => import(${JSON.stringify(`./${jsChunkFilePath}`)}).then(m => m.default)}`; } } @@ -197,13 +232,13 @@ export function generateAngularServerAppManifest( export default { bootstrap: () => import('./main.server.mjs').then(m => m.default), inlineCriticalCss: ${inlineCriticalCss}, - baseHref: '${baseHref}', + baseHref: ${JSON.stringify(baseHref)}, locale: ${JSON.stringify(locale)}, routes: ${JSON.stringify(routes, undefined, 2)}, entryPointToBrowserMapping: ${JSON.stringify(entryPointToBrowserMapping, undefined, 2)}, assets: { ${Object.entries(serverAssets) - .map(([key, value]) => `'${key}': ${value}`) + .map(([key, value]) => `${JSON.stringify(key)}: ${value}`) .join(',\n ')} }, }; diff --git a/packages/angular/build/src/utils/server-rendering/manifest_spec.ts b/packages/angular/build/src/utils/server-rendering/manifest_spec.ts new file mode 100644 index 000000000000..34e56de689ea --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/manifest_spec.ts @@ -0,0 +1,105 @@ +/** + * @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 { BuildOutputFileType, createOutputFile } from '../../tools/esbuild/bundler-files'; +import { initializeHash } from '../hash'; +import { generateAngularServerAppManifest } from './manifest'; + +/** + * Evaluates a generated manifest, which both asserts that it is syntactically valid JavaScript and + * gives access to the values it declares. The dynamic imports it contains are never invoked. + */ +function evaluateManifest(manifestContent: string): Record { + return new Function(manifestContent.replace('export default', 'return'))() as Record< + string, + unknown + >; +} + +function generateManifest( + htmlOutputFiles: Record, + baseHref = '/', +): ReturnType { + const additionalHtmlOutputFiles = new Map( + Object.entries(htmlOutputFiles).map(([path, content]) => [ + path, + createOutputFile(path, content, BuildOutputFileType.Browser), + ]), + ); + + return generateAngularServerAppManifest( + additionalHtmlOutputFiles, + [], + false, + undefined, + undefined, + baseHref, + new Set(), + { inputs: {}, outputs: {} }, + undefined, + ); +} + +describe('generateAngularServerAppManifest', () => { + beforeAll(async () => { + await initializeHash(); + }); + + it('serializes asset paths which contain JavaScript string delimiters', () => { + const assetPath = "catalog/customer's-choice/index.html"; + const { manifestContent } = generateManifest({ [assetPath]: '
Featured
' }); + + const assets = evaluateManifest(manifestContent)['assets'] as Record; + expect(Object.keys(assets)).toEqual([assetPath]); + }); + + it('serializes a base href which contains JavaScript string delimiters', () => { + const { manifestContent } = generateManifest({ 'index.html': '
' }, "/o'brien/"); + + expect(evaluateManifest(manifestContent)['baseHref']).toBe("/o'brien/"); + }); + + it('generates chunk names which are usable as a file name and as a module specifier', () => { + const assetPath = "catalog/customer's#featured?ratio=50%/index.html"; + const { serverAssetsChunks } = generateManifest({ [assetPath]: '
Featured
' }); + + expect(serverAssetsChunks).toHaveSize(1); + expect(serverAssetsChunks[0].path).toMatch(/^assets-chunks\/[a-zA-Z0-9_-]+\.mjs$/); + }); + + it('generates a dynamic import which resolves back to the emitted chunk', () => { + // The in-memory ESM loader used while prerendering resolves the specifier as a URL and looks the + // result up by output file path, so the two have to match exactly. + const assetPath = "catalog/customer's#featured?ratio=50%/index.html"; + const { manifestContent, serverAssetsChunks } = generateManifest({ + [assetPath]: '
Featured
', + }); + + const assets = evaluateManifest(manifestContent)['assets'] as Record< + string, + { text: () => Promise } + >; + const specifier = /import\("(.+?)"\)/.exec(assets[assetPath].text.toString())?.[1]; + + const root = 'file:///virtual/root/'; + expect(specifier).toBeDefined(); + expect(new URL(specifier as string, root).href.slice(root.length)).toBe( + serverAssetsChunks[0].path, + ); + }); + + it('generates a distinct chunk for asset paths which map to the same name', () => { + const { serverAssetsChunks } = generateManifest({ + 'foo/bar/index.html': '
nested
', + 'foo_bar/index.html': '
flat
', + }); + + expect(serverAssetsChunks).toHaveSize(2); + expect(serverAssetsChunks[0].path).not.toBe(serverAssetsChunks[1].path); + }); +}); diff --git a/packages/angular/build/src/utils/server-rendering/utils.ts b/packages/angular/build/src/utils/server-rendering/utils.ts index 2848fba52e75..18ab7def9ec1 100644 --- a/packages/angular/build/src/utils/server-rendering/utils.ts +++ b/packages/angular/build/src/utils/server-rendering/utils.ts @@ -22,26 +22,53 @@ export function isSsrRequestHandler( return typeof value === 'function' && '__ng_request_handler__' in value; } +/** + * A mapping of the characters which have to be escaped to be interpolated into HTML, + * to their entity equivalents. + */ +const HTML_ESCAPE_CHARACTER_MAP: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +/** + * Escapes the characters of a value which is interpolated into HTML text or into a quoted + * attribute value. + * + * @param text - The value to escape. + * @returns The escaped value. + */ +function escapeHtml(text: string): string { + return text.replace(/[&<>"']/g, (character) => HTML_ESCAPE_CHARACTER_MAP[character]); +} + /** * Generates a static HTML page with a meta refresh tag to redirect the user to a specified URL. * * This function creates a simple HTML page that performs a redirect using a meta tag. * It includes a fallback link in case the meta-refresh doesn't work. * + * The provided URL is HTML-escaped before being interpolated. + * * @param url - The URL to which the page should redirect. * @returns The HTML content of the static redirect page. */ export function generateRedirectStaticPage(url: string): string { + const escapedUrl = escapeHtml(url); + return ` Redirecting - + -
Redirecting to ${url}
+
Redirecting to ${escapedUrl}
`.trim(); diff --git a/packages/angular/build/src/utils/server-rendering/utils_spec.ts b/packages/angular/build/src/utils/server-rendering/utils_spec.ts new file mode 100644 index 000000000000..c8d738f8eec1 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/utils_spec.ts @@ -0,0 +1,34 @@ +/** + * @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 { generateRedirectStaticPage } from './utils'; + +describe('generateRedirectStaticPage', () => { + it('escapes the ampersands of a redirect target', () => { + const page = generateRedirectStaticPage('https://example.com/docs?from=ssg&next=/ssg'); + + expect(page).toContain( + '', + ); + expect(page).toContain( + '' + + 'https://example.com/docs?from=ssg&next=/ssg', + ); + }); + + it('escapes characters which would break out of the attribute or the tag', () => { + const page = generateRedirectStaticPage(`/">`); + + expect(page).not.toContain('').error, + ).toContain(`the 'data:' protocol is not supported as a redirect target`); + expect(normalizeAndValidateRedirect('JaVaScRiPt:alert(1)').error).toContain( + `the 'javascript:' protocol is not supported as a redirect target`, + ); + }); + + it('should detect the scheme of a target which is padded with whitespace', () => { + // The URL parser strips leading whitespace and control characters before applying the scheme + // grammar, so these carry a scheme even though they do not start with one. + expect(normalizeAndValidateRedirect(' javascript:alert(1)').error).toContain( + `the 'javascript:' protocol is not supported as a redirect target`, + ); + expect(normalizeAndValidateRedirect('\tdata:text/html,x').error).toContain( + `the 'data:' protocol is not supported as a redirect target`, + ); + expect(normalizeAndValidateRedirect(' https://example.com/docs')).toEqual({ + url: 'https://example.com/docs', + }); + }); + + it('should reject an absolute target which cannot be parsed', () => { + expect(normalizeAndValidateRedirect('http://').error).toContain( + `'http://' could not be parsed as a URL.`, + ); + }); + + it('should reject protocol-relative and backslash targets', () => { + expect(normalizeAndValidateRedirect('//evil.example').error).toContain( + `Protocol-relative paths ('//') and backslashes ('\\') are not supported.`, + ); + expect(normalizeAndValidateRedirect('/\\evil.example').error).toContain( + `Protocol-relative paths ('//') and backslashes ('\\') are not supported.`, + ); + }); + + it('should reject a target which the parser resolves into a protocol-relative path', () => { + // Resolving the dot segments produces a leading `//`, which the raw value did not have and + // which leaves the origin unchanged, so it has to be rejected on the resolved path. + for (const target of [ + '/..//evil.example', + './/evil.example', + '/docs/../..//evil.example', + '/%2e%2e//evil.example', + ]) { + expect(normalizeAndValidateRedirect(target).error) + .withContext(target) + .toContain(`It resolves to the protocol-relative path '//evil.example'.`); + } + }); }); }); diff --git a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server.ts b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server.ts index ff72cb8e8df2..a922d9e142e7 100644 --- a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server.ts +++ b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server.ts @@ -77,7 +77,7 @@ export default async function () { path: 'ssg/:id', renderMode: RenderMode.Prerender, headers: { 'x-custom': 'ssg-with-params' }, - getPrerenderParams: async() => [{id: 'one'}, {id: 'two'}], + getPrerenderParams: async() => [{id: 'one'}, {id: 'two'}, {id: "customer's-choice"}], }, { path: 'ssr', @@ -115,6 +115,7 @@ export default async function () { 'ssg/index.html': 'ssg works!', 'ssg/one/index.html': 'ssg-with-params works!', 'ssg/two/index.html': 'ssg-with-params works!', + "ssg/customer's-choice/index.html": 'ssg-with-params works!', }; for (const [filePath, fileMatch] of Object.entries(expects)) { diff --git a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static.ts b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static.ts index 77f954be4f4d..362cdc97e09e 100644 --- a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static.ts +++ b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static.ts @@ -48,6 +48,10 @@ export default async function () { path: 'ssg-redirect', redirectTo: 'ssg' }, + { + path: 'ssg-redirect-external', + component: Ssg, + }, { path: 'ssg-redirect-via-guard', canActivate: [() => { @@ -73,6 +77,11 @@ export default async function () { import { RenderMode, ServerRoute } from '@angular/ssr'; export const serverRoutes: ServerRoute[] = [ + { + path: 'ssg-redirect-external', + renderMode: RenderMode.Prerender, + headers: { Location: 'https://example.com/docs?from=ssg&next=/ssg' }, + }, { path: 'ssg/:id', renderMode: RenderMode.Prerender, @@ -115,6 +124,9 @@ export default async function () { 'ssg/two/index.html': /ng-server-context="ssg".+ssg-with-params works!/, // When static redirects are generated as meta tags. 'ssg-redirect/index.html': '', + // The target of a 'Location' header is HTML escaped before it is written to the page. + 'ssg-redirect-external/index.html': + '', 'ssg-redirect-via-guard/index.html': '', };