Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 { basename, 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';
Expand All @@ -28,6 +31,83 @@ 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.
*
* 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<T extends object> extends virtualFs.ScopedHost<T> {
private readonly _systemRoot: string;

constructor(delegate: virtualFs.Host<T>, root: Path) {
super(delegate, root);
this._systemRoot = resolveRealPath(getSystemPath(root));
}

private _assertWithinRoot(path: Path): void {
const real = resolveRealPath(getSystemPath(this._resolve(path)));

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<void> {
this._assertWithinRoot(path);

return super.write(path, content);
}

override delete(path: Path): Observable<void> {
this._assertWithinRoot(path);

return super.delete(path);
}

override rename(from: Path, to: Path): Observable<void> {
this._assertWithinRoot(from);
this._assertWithinRoot(to);

return super.rename(from, to);
}
}
Comment on lines +62 to +109

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If the workspace root directory does not exist yet (for example, during a schematic execution like ng new that creates the workspace directory), realpathSync(getSystemPath(root)) in the constructor will throw an ENOENT error and crash the process.

To make this robust, we can extract a helper function resolveRealPath that resolves the real path of any path (including the workspace root and target files) by walking up to the first existing ancestor, resolving its real path, and then appending the remaining non-existent segments. This also simplifies the _assertWithinRoot method by removing duplicate loop logic.

/**
 * 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 segments.
 */
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.
 *
 * 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<T extends object> extends virtualFs.ScopedHost<T> {
  private readonly _systemRoot: string;

  constructor(delegate: virtualFs.Host<T>, root: Path) {
    super(delegate, root);
    this._systemRoot = resolveRealPath(getSystemPath(root));
  }

  private _assertWithinRoot(path: Path): void {
    const systemPath = resolveSystemPath(getSystemPath(this._resolve(path)));
    const real = resolveRealPath(systemPath);

    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<void> {
    this._assertWithinRoot(path);

    return super.write(path, content);
  }

  override delete(path: Path): Observable<void> {
    this._assertWithinRoot(path);

    return super.delete(path);
  }

  override rename(from: Path, to: Path): Observable<void> {
    this._assertWithinRoot(from);
    this._assertWithinRoot(to);

    return super.rename(from, to);
  }
}


/**
* A workflow specifically for Node tools.
*/
Expand All @@ -41,7 +121,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;
Expand Down
Loading