diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index 99d5d67efbfd..db5c97d05002 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -308,14 +308,21 @@ export async function normalizeOptions( } const outputPath = options.outputPath ?? path.join(workspaceRoot, 'dist', projectName); + const resolvedOutputBase = path.resolve( + workspaceRoot, + typeof outputPath === 'string' ? outputPath : outputPath.base, + ); + if (!resolvedOutputBase.startsWith(workspaceRoot + path.sep)) { + throw new Error( + `Output path '${resolvedOutputBase}' must be inside the project root directory '${workspaceRoot}'.`, + ); + } const outputOptions: NormalizedOutputOptions = { browser: 'browser', server: 'server', media: 'media', ...(typeof outputPath === 'string' ? undefined : outputPath), - base: normalizeDirectoryPath( - path.resolve(workspaceRoot, typeof outputPath === 'string' ? outputPath : outputPath.base), - ), + base: normalizeDirectoryPath(resolvedOutputBase), clean: options.deleteOutputPath ?? true, // For app-shell and SSG server files are not required by users. // Omit these when SSR is not enabled. diff --git a/packages/angular/build/src/builders/application/tests/options/output-path_spec.ts b/packages/angular/build/src/builders/application/tests/options/output-path_spec.ts index b6c72b9bee58..1bf90e4b94cf 100644 --- a/packages/angular/build/src/builders/application/tests/options/output-path_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/output-path_spec.ts @@ -260,6 +260,22 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { }), ); }); + + it('should error when the output path escapes the workspace root', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + outputPath: '../dist', + }); + + const { result, error } = await harness.executeOnce({ + outputLogsOnException: false, + outputLogsOnFailure: false, + }); + + expect(result).toBeUndefined(); + expect(error?.message).toMatch(/must be inside the project root directory/); + }); }); }); }); diff --git a/packages/angular/build/src/utils/delete-output-dir.ts b/packages/angular/build/src/utils/delete-output-dir.ts index 45084760793d..cfb19baf8ead 100644 --- a/packages/angular/build/src/utils/delete-output-dir.ts +++ b/packages/angular/build/src/utils/delete-output-dir.ts @@ -7,20 +7,26 @@ */ import { readdir, rm } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; +import { join, resolve, sep } from 'node:path'; /** - * Delete an output directory, but error out if it's the root of the project. + * Delete an output directory, but error out if it's the root of the project or outside it. */ export async function deleteOutputDir( root: string, outputPath: string, emptyOnlyDirectories?: string[], ): Promise { + const resolvedRoot = resolve(root); const resolvedOutputPath = resolve(root, outputPath); - if (resolvedOutputPath === root) { + if (resolvedOutputPath === resolvedRoot) { throw new Error('Output path MUST not be project root directory!'); } + if (!resolvedOutputPath.startsWith(resolvedRoot + sep)) { + throw new Error( + `Output path '${resolvedOutputPath}' MUST be inside the project root '${resolvedRoot}'.`, + ); + } const directoriesToEmpty = emptyOnlyDirectories ? new Set(emptyOnlyDirectories.map((directory) => join(resolvedOutputPath, directory))) diff --git a/packages/angular/build/src/utils/delete-output-dir_spec.ts b/packages/angular/build/src/utils/delete-output-dir_spec.ts new file mode 100644 index 000000000000..7b0e39664795 --- /dev/null +++ b/packages/angular/build/src/utils/delete-output-dir_spec.ts @@ -0,0 +1,38 @@ +/** + * @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 { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { deleteOutputDir } from './delete-output-dir'; + +describe('deleteOutputDir', () => { + let workspaceRoot: string; + + beforeEach(async () => { + workspaceRoot = await mkdtemp(join(tmpdir(), 'angular-cli-test-')); + await mkdir(join(workspaceRoot, 'dist'), { recursive: true }); + await writeFile(join(workspaceRoot, 'dist', 'file.txt'), 'test'); + }); + + afterEach(async () => { + await rm(workspaceRoot, { recursive: true, force: true }); + }); + + it('should reject deleting the project root directory', async () => { + await expectAsync(deleteOutputDir(workspaceRoot, '.')).toBeRejectedWithError( + 'Output path MUST not be project root directory!', + ); + }); + + it('should reject deleting a path outside the project root', async () => { + await expectAsync(deleteOutputDir(workspaceRoot, '..')).toBeRejectedWithError( + new RegExp(`Output path '.*' MUST be inside the project root '${workspaceRoot}'.`), + ); + }); +}); \ No newline at end of file