From 33e009c5c2d55bd5b6c35af4b78d7f6c9dd5ad84 Mon Sep 17 00:00:00 2001 From: adilburaksen Date: Tue, 9 Jun 2026 23:47:37 +0300 Subject: [PATCH 1/2] fix(@angular-devkit/schematics): prevent schematic writes from escaping the workspace via symlinks A schematic/migration write can escape the workspace root via a symlinked directory inside the workspace: ScopedHost's containment is lexical and does not resolve symlinks. WorkspaceRootHost resolves the real (symlink-collapsed) path and rejects any write/delete/rename whose real location is outside the workspace root, mirroring the MCP host's realpath-based restriction. --- .../tools/workflow/node-workflow.ts | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/angular_devkit/schematics/tools/workflow/node-workflow.ts b/packages/angular_devkit/schematics/tools/workflow/node-workflow.ts index c8bf5fee5354..6ee1cf7a7db0 100644 --- a/packages/angular_devkit/schematics/tools/workflow/node-workflow.ts +++ b/packages/angular_devkit/schematics/tools/workflow/node-workflow.ts @@ -8,6 +8,9 @@ import { Path, getSystemPath, normalize, schema, virtualFs } from '@angular-devkit/core'; import { NodeJsSyncHost } from '@angular-devkit/core/node'; +import { realpathSync } from 'node:fs'; +import { dirname, isAbsolute, relative, resolve as resolveSystemPath, sep } from 'node:path'; +import { Observable } from 'rxjs'; import { workflow } from '../../src'; import { BuiltinTaskExecutor } from '../../tasks/node'; import { FileSystemEngine } from '../description'; @@ -28,6 +31,72 @@ export interface NodeWorkflowOptions { engineHostCreator?: (options: NodeWorkflowOptions) => NodeModulesEngineHost; } +/** + * A {@link virtualFs.ScopedHost} that additionally rejects any write/delete/rename whose real + * (symlink-resolved) location escapes the workspace root. + * + * The lexical containment of `ScopedHost` (and the schematics `Tree`, which rejects `..`) does not + * resolve symlinks, so a workspace that contains a symlinked directory could otherwise route a + * schematic/migration write to a file outside the workspace. This mirrors the realpath-based root + * restriction already used by the MCP host (`createRootRestrictedHost`). + */ +class WorkspaceRootHost extends virtualFs.ScopedHost { + private readonly _systemRoot: string; + + constructor(delegate: virtualFs.Host, root: Path) { + super(delegate, root); + this._systemRoot = realpathSync(getSystemPath(root)); + } + + private _assertWithinRoot(path: Path): void { + // Resolve the real path, walking up to the first existing ancestor for not-yet-created files. + let current = resolveSystemPath(getSystemPath(this._resolve(path))); + let real: string; + for (;;) { + try { + real = realpathSync(current); + break; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + throw e; + } + const parent = dirname(current); + if (parent === current) { + throw e; + } + current = parent; + } + } + + const rel = relative(this._systemRoot, real); + if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) { + throw new Error( + `Schematic attempted to access a path outside of the workspace root: ` + + getSystemPath(this._resolve(path)), + ); + } + } + + override write(path: Path, content: virtualFs.FileBuffer): Observable { + this._assertWithinRoot(path); + + return super.write(path, content); + } + + override delete(path: Path): Observable { + this._assertWithinRoot(path); + + return super.delete(path); + } + + override rename(from: Path, to: Path): Observable { + this._assertWithinRoot(from); + this._assertWithinRoot(to); + + return super.rename(from, to); + } +} + /** * A workflow specifically for Node tools. */ @@ -41,7 +110,7 @@ export class NodeWorkflow extends workflow.BaseWorkflow { let root; if (typeof hostOrRoot === 'string') { root = normalize(hostOrRoot); - host = new virtualFs.ScopedHost(new NodeJsSyncHost(), root); + host = new WorkspaceRootHost(new NodeJsSyncHost(), root); } else { host = hostOrRoot; root = options.root; From 1e735946b7363f44634154e0371a78c40df6ce38 Mon Sep 17 00:00:00 2001 From: adilburaksen Date: Wed, 10 Jun 2026 01:43:42 +0300 Subject: [PATCH 2/2] fix(@angular-devkit/schematics): handle non-existent paths in workspace-root containment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous WorkspaceRootHost resolved the workspace root with realpathSync(getSystemPath(root)) in the constructor, which throws ENOENT when the root directory does not exist yet — e.g. during `ng new`, which creates the workspace — crashing the workflow. Extract a resolveRealPath helper that walks up to the first existing ancestor, resolves its real path, and re-appends the remaining non-existent segments. Use it for both the workspace root and the asserted target path, so containment works for not-yet-created files and a not-yet-created root while still rejecting symlink escapes. --- .../tools/workflow/node-workflow.ts | 51 +++++++++++-------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/packages/angular_devkit/schematics/tools/workflow/node-workflow.ts b/packages/angular_devkit/schematics/tools/workflow/node-workflow.ts index 6ee1cf7a7db0..917c6a06bc26 100644 --- a/packages/angular_devkit/schematics/tools/workflow/node-workflow.ts +++ b/packages/angular_devkit/schematics/tools/workflow/node-workflow.ts @@ -9,7 +9,7 @@ import { Path, getSystemPath, normalize, schema, virtualFs } from '@angular-devkit/core'; import { NodeJsSyncHost } from '@angular-devkit/core/node'; import { realpathSync } from 'node:fs'; -import { dirname, isAbsolute, relative, resolve as resolveSystemPath, sep } from 'node:path'; +import { basename, dirname, isAbsolute, relative, resolve as resolveSystemPath, sep } from 'node:path'; import { Observable } from 'rxjs'; import { workflow } from '../../src'; import { BuiltinTaskExecutor } from '../../tasks/node'; @@ -31,6 +31,34 @@ export interface NodeWorkflowOptions { engineHostCreator?: (options: NodeWorkflowOptions) => NodeModulesEngineHost; } +/** + * Resolves the real path of a system path, walking up to the first existing ancestor if the path or + * its descendants do not exist, and preserving the non-existent trailing segments. This keeps the + * containment check working for not-yet-created files and for a workspace root that does not exist + * yet (e.g. during `ng new`), where `realpathSync` would otherwise throw `ENOENT`. + */ +function resolveRealPath(systemPath: string): string { + let current = resolveSystemPath(systemPath); + const segments: string[] = []; + for (;;) { + try { + const real = realpathSync(current); + + return resolveSystemPath(real, ...segments.reverse()); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + throw e; + } + const parent = dirname(current); + if (parent === current) { + throw e; + } + segments.push(basename(current)); + current = parent; + } + } +} + /** * A {@link virtualFs.ScopedHost} that additionally rejects any write/delete/rename whose real * (symlink-resolved) location escapes the workspace root. @@ -45,28 +73,11 @@ class WorkspaceRootHost extends virtualFs.ScopedHost { constructor(delegate: virtualFs.Host, root: Path) { super(delegate, root); - this._systemRoot = realpathSync(getSystemPath(root)); + this._systemRoot = resolveRealPath(getSystemPath(root)); } private _assertWithinRoot(path: Path): void { - // Resolve the real path, walking up to the first existing ancestor for not-yet-created files. - let current = resolveSystemPath(getSystemPath(this._resolve(path))); - let real: string; - for (;;) { - try { - real = realpathSync(current); - break; - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { - throw e; - } - const parent = dirname(current); - if (parent === current) { - throw e; - } - current = parent; - } - } + const real = resolveRealPath(getSystemPath(this._resolve(path))); const rel = relative(this._systemRoot, real); if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) {