diff --git a/packages/angular/build/src/builders/application/tests/options/assets_spec.ts b/packages/angular/build/src/builders/application/tests/options/assets_spec.ts
index 96ae3c0d943e..fad28f5635da 100644
--- a/packages/angular/build/src/builders/application/tests/options/assets_spec.ts
+++ b/packages/angular/build/src/builders/application/tests/options/assets_spec.ts
@@ -359,6 +359,27 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
harness.expectFile('dist/browser/subdirectory/test.svg').content.toBe('');
});
+ it('fails if asset input option is outside workspace root (relative)', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ assets: [{ glob: '**/*', input: '../outside', output: '.' }],
+ });
+
+ const { error } = await harness.executeOnce({ outputLogsOnException: false });
+
+ expect(error?.message).toContain('asset path must be within the workspace root');
+ });
+
+ it('fails if asset input option is outside workspace root (absolute)', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ assets: [{ glob: '**/*', input: '/tmp/outside-workspace', output: '.' }],
+ });
+
+ const { error } = await harness.executeOnce({ outputLogsOnException: false });
+
+ expect(error?.message).toContain('asset path must be within the workspace root');
+ });
it('fails if output option is not within project output path', async () => {
await harness.writeFile('test.svg', '');
diff --git a/packages/angular/build/src/utils/normalize-asset-patterns.ts b/packages/angular/build/src/utils/normalize-asset-patterns.ts
index 8a8b2c2cbf1f..396d789f8634 100644
--- a/packages/angular/build/src/utils/normalize-asset-patterns.ts
+++ b/packages/angular/build/src/utils/normalize-asset-patterns.ts
@@ -10,6 +10,7 @@ import assert from 'node:assert';
import { statSync } from 'node:fs';
import * as path from 'node:path';
import { AssetPattern, AssetPatternClass } from '../builders/application/schema';
+import { isSubDirectory } from './path';
export class MissingAssetSourceRootException extends Error {
constructor(path: string) {
@@ -68,6 +69,10 @@ export function normalizeAssetPatterns(
assetPattern = { glob, input, output };
} else {
+ if (!isSubDirectory(workspaceRoot, assetPattern.input)) {
+ throw new Error(`The ${assetPattern.input} asset path must be within the workspace root.`);
+ }
+
assetPattern.output = path.join('.', assetPattern.output ?? '');
}
diff --git a/packages/angular/build/src/utils/path.ts b/packages/angular/build/src/utils/path.ts
index 036dcb23502e..eafef4ee9f2b 100644
--- a/packages/angular/build/src/utils/path.ts
+++ b/packages/angular/build/src/utils/path.ts
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
-import { posix } from 'node:path';
+import { normalize, posix, resolve } from 'node:path';
import { platform } from 'node:process';
const WINDOWS_PATH_SEPERATOR_REGEXP = /\\/g;
@@ -35,3 +35,17 @@ const WINDOWS_PATH_SEPERATOR_REGEXP = /\\/g;
export function toPosixPath(path: string): string {
return platform === 'win32' ? path.replace(WINDOWS_PATH_SEPERATOR_REGEXP, posix.sep) : path;
}
+
+/**
+ * Determines if a path is a subdirectory or file within a parent directory.
+ *
+ * @param parent - The parent directory path.
+ * @param child - The child path to check.
+ * @returns `true` if the child path is within the parent directory, `false` otherwise.
+ */
+export function isSubDirectory(parent: string, child: string): boolean {
+ const normalizedParent = normalize(parent);
+ const resolvedChild = resolve(parent, child);
+
+ return resolvedChild.startsWith(normalizedParent);
+}
diff --git a/packages/angular/build/src/utils/resolve-assets.ts b/packages/angular/build/src/utils/resolve-assets.ts
index e98879e58de7..71b1c0e4768b 100644
--- a/packages/angular/build/src/utils/resolve-assets.ts
+++ b/packages/angular/build/src/utils/resolve-assets.ts
@@ -8,6 +8,7 @@
import path from 'node:path';
import { glob } from 'tinyglobby';
+import { isSubDirectory } from './path';
export async function resolveAssets(
entries: {
@@ -25,7 +26,12 @@ export async function resolveAssets(
const outputFiles: { source: string; destination: string }[] = [];
for (const entry of entries) {
+ if (!isSubDirectory(root, entry.input)) {
+ throw new Error(`The ${entry.input} asset path must be within the workspace root.`);
+ }
+
const cwd = path.resolve(root, entry.input);
+
const files = await glob(entry.glob, {
cwd,
dot: true,
diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/assets_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/assets_spec.ts
index b8752e7c275e..c7ac266cf036 100644
--- a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/assets_spec.ts
+++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/assets_spec.ts
@@ -359,6 +359,28 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => {
harness.expectFile('dist/subdirectory/test.svg').content.toBe('');
});
+ it('fails if asset input option is outside workspace root (relative)', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ assets: [{ glob: '**/*', input: '../outside', output: '.' }],
+ });
+
+ const { result } = await harness.executeOnce();
+
+ expect(result?.error).toMatch('asset path must be within the workspace root');
+ });
+
+ it('fails if asset input option is outside workspace root (absolute)', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ assets: [{ glob: '**/*', input: '/tmp/outside-workspace', output: '.' }],
+ });
+
+ const { result } = await harness.executeOnce();
+
+ expect(result?.error).toMatch('asset path must be within the workspace root');
+ });
+
it('fails if output option is not within project output path', async () => {
await harness.writeFile('test.svg', '');
diff --git a/packages/angular_devkit/build_angular/src/utils/normalize-asset-patterns.ts b/packages/angular_devkit/build_angular/src/utils/normalize-asset-patterns.ts
index ef421d81042c..20e97e1d5162 100644
--- a/packages/angular_devkit/build_angular/src/utils/normalize-asset-patterns.ts
+++ b/packages/angular_devkit/build_angular/src/utils/normalize-asset-patterns.ts
@@ -43,7 +43,7 @@ export function normalizeAssetPatterns(
}
let glob: string, input: string;
- let isDirectory = false;
+ let isDirectory: boolean;
try {
isDirectory = statSync(resolvedAssetPath).isDirectory();
@@ -68,6 +68,11 @@ export function normalizeAssetPatterns(
assetPattern = { glob, input, output };
} else {
+ const resolvedInput = path.resolve(workspaceRoot, assetPattern.input);
+ if (!resolvedInput.startsWith(workspaceRoot)) {
+ throw new Error(`The ${assetPattern.input} asset path must be within the workspace root.`);
+ }
+
assetPattern.output = path.join('.', assetPattern.output ?? '');
}
diff --git a/tests/legacy-cli/e2e/tests/commands/serve/assets.ts b/tests/legacy-cli/e2e/tests/commands/serve/assets.ts
index 83fcb42aa8f2..3ddd7f8b74b1 100644
--- a/tests/legacy-cli/e2e/tests/commands/serve/assets.ts
+++ b/tests/legacy-cli/e2e/tests/commands/serve/assets.ts
@@ -1,59 +1,30 @@
import assert from 'node:assert';
-import { randomUUID } from 'node:crypto';
-import { mkdir, rm, writeFile } from 'node:fs/promises';
-import { ngServe, updateJsonFile } from '../../../utils/project';
+import { ngServe } from '../../../utils/project';
import { getGlobalVariable } from '../../../utils/env';
export default async function () {
- const outsideDirectoryName = `../outside-${randomUUID()}`;
+ const port = await ngServe();
+ let response = await fetch(`http://localhost:${port}/favicon.ico`);
+ assert.strictEqual(response.status, 200, 'favicon.ico response should be ok');
- await updateJsonFile('angular.json', (json) => {
- // Ensure assets located outside the workspace root work with the dev server
- json.projects['test-project'].architect.build.options.assets.push({
- 'input': outsideDirectoryName,
- 'glob': '**/*',
- 'output': './outside',
- });
+ // A non-existent HTML file request with accept header should fallback to the index HTML
+ response = await fetch(`http://localhost:${port}/does-not-exist.html`, {
+ headers: { accept: 'text/html' },
});
-
- await mkdir(outsideDirectoryName);
- try {
- await writeFile(`${outsideDirectoryName}/some-asset.xyz`, 'XYZ');
-
- const port = await ngServe();
-
- let response = await fetch(`http://localhost:${port}/favicon.ico`);
- assert.strictEqual(response.status, 200, 'favicon.ico response should be ok');
-
- response = await fetch(`http://localhost:${port}/outside/some-asset.xyz`);
- assert.strictEqual(response.status, 200, 'outside/some-asset.xyz response should be ok');
- assert.strictEqual(await response.text(), 'XYZ', 'outside/some-asset.xyz content is wrong');
-
- // A non-existent HTML file request with accept header should fallback to the index HTML
- response = await fetch(`http://localhost:${port}/does-not-exist.html`, {
- headers: { accept: 'text/html' },
- });
- assert.strictEqual(
- response.status,
- 200,
- 'non-existent file response should fallback and be ok',
- );
- assert.match(
- await response.text(),
- /