From cabe5ca31c16c3a2219a1c4fb17aa0a8f3dc3c6d Mon Sep 17 00:00:00 2001 From: adilburaksen Date: Mon, 8 Jun 2026 13:52:38 +0300 Subject: [PATCH] fix(@angular-devkit/schematics): prevent schematic writes from escaping the workspace via symlinks The schematics `Tree` and `ScopedHost` confine writes to the workspace only lexically: `_normalizePath` rejects `..` escapes, and `ScopedHost._resolve` joins paths to the workspace root. But the real-filesystem commit (`NodeJsSyncHost.write`/`delete`/`rename`) uses `writeFileSync`/`rmSync`/ `renameSync`, which follow symlinks, with no realpath check. So if a workspace contains a symlinked directory pointing outside it (e.g. from a cloned repo), a built-in schematic or `ng update` migration writing a lexically in-workspace path can create/overwrite/delete a file outside the workspace. This wraps the NodeWorkflow's host so write/delete/rename assert that the real (symlink-resolved) path stays within the workspace root, mirroring the realpath-based restriction already used by the MCP host (`createRootRestrictedHost`). In-workspace operations are unaffected. Verified against the published packages: a real `use-application-builder` migration whose `karmaConfig` resolves through a symlinked directory no longer overwrites the outside target, while the same migration on an in-workspace config still applies. --- .../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..1a3a760944c5 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 } 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.startsWith('..') || 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;