Skip to content

Commit d9a1c8a

Browse files
committed
fix(@angular/build): restrict application builder output paths to output directory
Ensure all file writes and deletions produced by the application builder are strictly scoped to the configured `outputPath` base directory. This prevents path traversal vulnerabilities (e.g. setting `browser` directory to `..` or relative segments that escape the output base folder) by checking all generated paths against a refined `isSubDirectory` helper before any file system write or deletion is performed.
1 parent 11a4438 commit d9a1c8a

4 files changed

Lines changed: 119 additions & 36 deletions

File tree

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

Lines changed: 51 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { createJsonBuildManifest, emitFilesToDisk } from '../../tools/esbuild/ut
1515
import { colors as ansiColors } from '../../utils/color';
1616
import { deleteOutputDir } from '../../utils/delete-output-dir';
1717
import { bazelEsbuildPluginPath, useJSONBuildLogs } from '../../utils/environment-options';
18+
import { isSubDirectory } from '../../utils/path';
1819
import { purgeStaleBuildCache } from '../../utils/purge-cache';
1920
import { assertCompatibleAngularVersion } from '../../utils/version';
2021
import { runEsBuildBuildAction } from './build-action';
@@ -197,45 +198,57 @@ export async function* buildApplication(
197198

198199
// Writes the output files to disk and ensures the containing directories are present
199200
const directoryExists = new Set<string>();
200-
await emitFilesToDisk(Object.entries(result.files), async ([filePath, file]) => {
201-
if (
202-
outputOptions.ignoreServer &&
203-
(file.type === BuildOutputFileType.ServerApplication ||
204-
file.type === BuildOutputFileType.ServerRoot)
205-
) {
206-
return;
207-
}
201+
try {
202+
await emitFilesToDisk(Object.entries(result.files), async ([filePath, file]) => {
203+
if (
204+
outputOptions.ignoreServer &&
205+
(file.type === BuildOutputFileType.ServerApplication ||
206+
file.type === BuildOutputFileType.ServerRoot)
207+
) {
208+
return;
209+
}
208210

209-
const fullFilePath = generateFullPath(filePath, file.type, outputOptions);
211+
const fullFilePath = generateFullPath(filePath, file.type, outputOptions);
210212

211-
// Ensure output subdirectories exist
212-
const fileBasePath = path.dirname(fullFilePath);
213-
if (fileBasePath && !directoryExists.has(fileBasePath)) {
214-
await fs.mkdir(fileBasePath, { recursive: true });
215-
directoryExists.add(fileBasePath);
216-
}
213+
// Ensure output subdirectories exist
214+
const fileBasePath = path.dirname(fullFilePath);
215+
if (fileBasePath && !directoryExists.has(fileBasePath)) {
216+
await fs.mkdir(fileBasePath, { recursive: true });
217+
directoryExists.add(fileBasePath);
218+
}
217219

218-
if (file.origin === 'memory') {
219-
// Write file contents
220-
await fs.writeFile(fullFilePath, file.contents);
221-
} else {
222-
// Copy file contents
223-
await fs.cp(file.inputPath, fullFilePath, {
224-
mode: fs.constants.COPYFILE_FICLONE,
225-
preserveTimestamps: true,
226-
});
227-
}
228-
});
220+
if (file.origin === 'memory') {
221+
// Write file contents
222+
await fs.writeFile(fullFilePath, file.contents);
223+
} else {
224+
// Copy file contents
225+
await fs.cp(file.inputPath, fullFilePath, {
226+
mode: fs.constants.COPYFILE_FICLONE,
227+
preserveTimestamps: true,
228+
});
229+
}
230+
});
231+
} catch (error) {
232+
context.logger.error(error instanceof Error ? error.message : String(error));
233+
yield { success: false };
234+
continue;
235+
}
229236

230237
// Delete any removed files if incremental
231238
if (result.kind === ResultKind.Incremental && result.removed?.length) {
232-
await Promise.all(
233-
result.removed.map((file) => {
234-
const fullFilePath = generateFullPath(file.path, file.type, outputOptions);
239+
try {
240+
await Promise.all(
241+
result.removed.map((file) => {
242+
const fullFilePath = generateFullPath(file.path, file.type, outputOptions);
235243

236-
return fs.rm(fullFilePath, { force: true, maxRetries: 3 });
237-
}),
238-
);
244+
return fs.rm(fullFilePath, { force: true, maxRetries: 3 });
245+
}),
246+
);
247+
} catch (error) {
248+
context.logger.error(error instanceof Error ? error.message : String(error));
249+
yield { success: false };
250+
continue;
251+
}
239252
}
240253

241254
yield { success: true };
@@ -268,6 +281,12 @@ function generateFullPath(
268281
// NOTE: 'base' is a fully resolved path at this point
269282
const fullFilePath = path.join(outputOptions.base, typeDirectory, filePath);
270283

284+
if (!isSubDirectory(outputOptions.base, fullFilePath)) {
285+
throw new Error(
286+
`The output file path "${fullFilePath}" is outside of the configured output path "${outputOptions.base}".`,
287+
);
288+
}
289+
271290
return fullFilePath;
272291
}
273292

packages/angular/build/src/builders/application/tests/options/output-path_spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,28 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
260260
}),
261261
);
262262
});
263+
264+
it('should error when browser directory escapes the output path base', async () => {
265+
harness.useTarget('build', {
266+
...BASE_OPTIONS,
267+
polyfills: [],
268+
outputPath: {
269+
base: 'dist',
270+
browser: '..',
271+
},
272+
ssr: false,
273+
});
274+
275+
const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false });
276+
expect(result?.success).toBeFalse();
277+
expect(logs).toContain(
278+
jasmine.objectContaining({
279+
message: jasmine.stringMatching(
280+
`The output file path .* is outside of the configured output path`,
281+
),
282+
}),
283+
);
284+
});
263285
});
264286
});
265287
});

packages/angular/build/src/utils/path.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { normalize, posix, resolve } from 'node:path';
9+
import { isAbsolute, posix, resolve } from 'node:path';
1010
import { platform } from 'node:process';
1111

1212
const WINDOWS_PATH_SEPERATOR_REGEXP = /\\/g;
@@ -44,8 +44,9 @@ export function toPosixPath(path: string): string {
4444
* @returns `true` if the child path is within the parent directory, `false` otherwise.
4545
*/
4646
export function isSubDirectory(parent: string, child: string): boolean {
47-
const normalizedParent = normalize(parent);
48-
const resolvedChild = resolve(parent, child);
47+
const resolvedParent = toPosixPath(resolve(parent));
48+
const resolvedChild = toPosixPath(resolve(parent, child));
49+
const relativePath = posix.relative(resolvedParent, resolvedChild);
4950

50-
return resolvedChild.startsWith(normalizedParent);
51+
return relativePath !== '..' && !relativePath.startsWith('../') && !isAbsolute(relativePath);
5152
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { isSubDirectory } from './path';
10+
11+
describe('isSubDirectory', () => {
12+
it('should return true for a direct child', () => {
13+
expect(isSubDirectory('/foo/bar', '/foo/bar/baz')).toBeTrue();
14+
});
15+
16+
it('should return true for a nested child', () => {
17+
expect(isSubDirectory('/foo/bar', '/foo/bar/baz/qux/file.txt')).toBeTrue();
18+
});
19+
20+
it('should return true if paths are identical', () => {
21+
expect(isSubDirectory('/foo/bar', '/foo/bar')).toBeTrue();
22+
});
23+
24+
it('should return false for a sibling directory starting with same prefix', () => {
25+
expect(isSubDirectory('/foo/bar', '/foo/bar-outside')).toBeFalse();
26+
expect(isSubDirectory('/foo/bar', '/foo/bar-outside/file.txt')).toBeFalse();
27+
});
28+
29+
it('should return false for directory outside parent via traversal', () => {
30+
expect(isSubDirectory('/foo/bar', '/foo/bar/../outside')).toBeFalse();
31+
});
32+
33+
it('should return false for parent directory', () => {
34+
expect(isSubDirectory('/foo/bar', '/foo')).toBeFalse();
35+
});
36+
37+
it('should return true for children containing directory names starting with double dots', () => {
38+
expect(isSubDirectory('/foo/bar', '/foo/bar/..baz')).toBeTrue();
39+
expect(isSubDirectory('/foo/bar', '/foo/bar/..baz/qux')).toBeTrue();
40+
});
41+
});

0 commit comments

Comments
 (0)