From 019094505ea094b20780d73980b639be70052f4b Mon Sep 17 00:00:00 2001 From: CodeByMoriarty Date: Fri, 10 Apr 2026 19:17:10 -0400 Subject: [PATCH] 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)))