Skip to content

Commit 166641d

Browse files
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 '<resolved>' must be inside the project root directory '<workspaceRoot>'. 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 '<resolved>' MUST be inside the project root '<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.
1 parent 0f2342c commit 166641d

File tree

2 files changed

+22
-6
lines changed

2 files changed

+22
-6
lines changed

packages/angular/build/src/builders/application/options.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -308,14 +308,24 @@ export async function normalizeOptions(
308308
}
309309

310310
const outputPath = options.outputPath ?? path.join(workspaceRoot, 'dist', projectName);
311+
const resolvedOutputBase = path.resolve(
312+
workspaceRoot,
313+
typeof outputPath === 'string' ? outputPath : outputPath.base,
314+
);
315+
if (
316+
resolvedOutputBase !== workspaceRoot &&
317+
!resolvedOutputBase.startsWith(workspaceRoot + path.sep)
318+
) {
319+
throw new Error(
320+
`Output path '${resolvedOutputBase}' must be inside the project root directory '${workspaceRoot}'.`,
321+
);
322+
}
311323
const outputOptions: NormalizedOutputOptions = {
312324
browser: 'browser',
313325
server: 'server',
314326
media: 'media',
315327
...(typeof outputPath === 'string' ? undefined : outputPath),
316-
base: normalizeDirectoryPath(
317-
path.resolve(workspaceRoot, typeof outputPath === 'string' ? outputPath : outputPath.base),
318-
),
328+
base: normalizeDirectoryPath(resolvedOutputBase),
319329
clean: options.deleteOutputPath ?? true,
320330
// For app-shell and SSG server files are not required by users.
321331
// Omit these when SSR is not enabled.

packages/angular/build/src/utils/delete-output-dir.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,26 @@
77
*/
88

99
import { readdir, rm } from 'node:fs/promises';
10-
import { join, resolve } from 'node:path';
10+
import { join, resolve, sep } from 'node:path';
1111

1212
/**
13-
* Delete an output directory, but error out if it's the root of the project.
13+
* Delete an output directory, but error out if it's the root of the project or outside it.
1414
*/
1515
export async function deleteOutputDir(
1616
root: string,
1717
outputPath: string,
1818
emptyOnlyDirectories?: string[],
1919
): Promise<void> {
20+
const resolvedRoot = resolve(root);
2021
const resolvedOutputPath = resolve(root, outputPath);
21-
if (resolvedOutputPath === root) {
22+
if (resolvedOutputPath === resolvedRoot) {
2223
throw new Error('Output path MUST not be project root directory!');
2324
}
25+
if (!resolvedOutputPath.startsWith(resolvedRoot + sep)) {
26+
throw new Error(
27+
`Output path '${resolvedOutputPath}' MUST be inside the project root '${resolvedRoot}'.`,
28+
);
29+
}
2430

2531
const directoriesToEmpty = emptyOnlyDirectories
2632
? new Set(emptyOnlyDirectories.map((directory) => join(resolvedOutputPath, directory)))

0 commit comments

Comments
 (0)