Skip to content

Commit 33e009c

Browse files
committed
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.
1 parent 96202a3 commit 33e009c

1 file changed

Lines changed: 70 additions & 1 deletion

File tree

packages/angular_devkit/schematics/tools/workflow/node-workflow.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88

99
import { Path, getSystemPath, normalize, schema, virtualFs } from '@angular-devkit/core';
1010
import { NodeJsSyncHost } from '@angular-devkit/core/node';
11+
import { realpathSync } from 'node:fs';
12+
import { dirname, isAbsolute, relative, resolve as resolveSystemPath, sep } from 'node:path';
13+
import { Observable } from 'rxjs';
1114
import { workflow } from '../../src';
1215
import { BuiltinTaskExecutor } from '../../tasks/node';
1316
import { FileSystemEngine } from '../description';
@@ -28,6 +31,72 @@ export interface NodeWorkflowOptions {
2831
engineHostCreator?: (options: NodeWorkflowOptions) => NodeModulesEngineHost;
2932
}
3033

34+
/**
35+
* A {@link virtualFs.ScopedHost} that additionally rejects any write/delete/rename whose real
36+
* (symlink-resolved) location escapes the workspace root.
37+
*
38+
* The lexical containment of `ScopedHost` (and the schematics `Tree`, which rejects `..`) does not
39+
* resolve symlinks, so a workspace that contains a symlinked directory could otherwise route a
40+
* schematic/migration write to a file outside the workspace. This mirrors the realpath-based root
41+
* restriction already used by the MCP host (`createRootRestrictedHost`).
42+
*/
43+
class WorkspaceRootHost<T extends object> extends virtualFs.ScopedHost<T> {
44+
private readonly _systemRoot: string;
45+
46+
constructor(delegate: virtualFs.Host<T>, root: Path) {
47+
super(delegate, root);
48+
this._systemRoot = realpathSync(getSystemPath(root));
49+
}
50+
51+
private _assertWithinRoot(path: Path): void {
52+
// Resolve the real path, walking up to the first existing ancestor for not-yet-created files.
53+
let current = resolveSystemPath(getSystemPath(this._resolve(path)));
54+
let real: string;
55+
for (;;) {
56+
try {
57+
real = realpathSync(current);
58+
break;
59+
} catch (e) {
60+
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
61+
throw e;
62+
}
63+
const parent = dirname(current);
64+
if (parent === current) {
65+
throw e;
66+
}
67+
current = parent;
68+
}
69+
}
70+
71+
const rel = relative(this._systemRoot, real);
72+
if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) {
73+
throw new Error(
74+
`Schematic attempted to access a path outside of the workspace root: ` +
75+
getSystemPath(this._resolve(path)),
76+
);
77+
}
78+
}
79+
80+
override write(path: Path, content: virtualFs.FileBuffer): Observable<void> {
81+
this._assertWithinRoot(path);
82+
83+
return super.write(path, content);
84+
}
85+
86+
override delete(path: Path): Observable<void> {
87+
this._assertWithinRoot(path);
88+
89+
return super.delete(path);
90+
}
91+
92+
override rename(from: Path, to: Path): Observable<void> {
93+
this._assertWithinRoot(from);
94+
this._assertWithinRoot(to);
95+
96+
return super.rename(from, to);
97+
}
98+
}
99+
31100
/**
32101
* A workflow specifically for Node tools.
33102
*/
@@ -41,7 +110,7 @@ export class NodeWorkflow extends workflow.BaseWorkflow {
41110
let root;
42111
if (typeof hostOrRoot === 'string') {
43112
root = normalize(hostOrRoot);
44-
host = new virtualFs.ScopedHost(new NodeJsSyncHost(), root);
113+
host = new WorkspaceRootHost(new NodeJsSyncHost(), root);
45114
} else {
46115
host = hostOrRoot;
47116
root = options.root;

0 commit comments

Comments
 (0)