From 019094505ea094b20780d73980b639be70052f4b Mon Sep 17 00:00:00 2001 From: CodeByMoriarty Date: Fri, 10 Apr 2026 19:17:10 -0400 Subject: [PATCH 1/2] fix(@angular/build): reject outputPath that escapes workspace root Prevent a malicious angular.json from setting outputPath outside the workspace root (e.g. ".."), which caused the default application builder to recursively delete the workspace parent directory and sibling content before writing build output there. **Current behavior:** normalizeOptions() resolves the user-supplied outputPath (e.g. "..") relative to workspaceRoot without validating that the result stays inside the workspace. deleteOutputDir() only rejects a path that is exactly equal to the workspace root; it accepts any other path, including ancestors or siblings. A malicious angular.json with build.options.outputPath set to ".." causes a default ng build to: 1. Resolve the output base to the workspace's parent directory. 2. Recursively delete every entry under that parent (including the workspace itself and any sibling files/directories) as part of normal pre-build cleanup. 3. Write browser build artefacts into the parent directory. 4. Crash with ENOENT when it later tries to read assets from the now-deleted workspace. Severity: High - a single ng build can destroy the victim workspace and writable sibling content, then write build output into an attacker-chosen parent directory. **New behavior:** Two defence-in-depth guards are added: 1. Early validation in normalizeOptions() (packages/angular/build/src/builders/application/options.ts) After resolving the output base, the function now checks that it is a strict descendant of workspaceRoot. If not, it throws immediately before any filesystem work begins: Error: Output path '' must be inside the project root directory ''. 2. Boundary check in deleteOutputDir() (packages/angular/build/src/utils/delete-output-dir.ts) The deletion helper now rejects any outputPath whose resolved form is not a strict descendant of the workspace root (previously it only rejected an exact match). This acts as a last-resort guard even if upstream validation is bypassed: Error: Output path '' MUST be inside the project root ''. **Breaking change:** None. outputPath values that already point inside the workspace are unaffected. Only values that resolve to a path at or above workspaceRoot are now rejected with a clear error, which was never a supported or intended configuration. Validated with the PoC in security-reports/002-output-path-outside-workspace-deletion/. Before the fix the workspace was deleted and the build wrote output into the parent directory. After the fix the build aborts immediately with the new error message and nothing outside the workspace is touched. Fixes security report 002-output-path-outside-workspace-deletion. --- .../build/src/builders/application/options.ts | 13 ++++++++++--- .../angular/build/src/utils/delete-output-dir.ts | 12 +++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) 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/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))) From 858c4d6d4ef1c2e8c868788faac437b058b536cf Mon Sep 17 00:00:00 2001 From: CodeByMoriarty Date: Mon, 13 Apr 2026 06:08:28 +0800 Subject: [PATCH 2/2] test(@angular/build): add output path boundary checks --- .../tests/options/output-path_spec.ts | 16 ++++++++ .../build/src/utils/delete-output-dir_spec.ts | 38 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 packages/angular/build/src/utils/delete-output-dir_spec.ts 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_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