Prevent writing outside of the workspace roots (#6178)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Tommaso Sciortino
2025-08-14 15:59:37 -07:00
committed by GitHub
parent f47af1607a
commit 1a41ba7daf
2 changed files with 258 additions and 251 deletions

View File

@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { isNodeError } from '../utils/errors.js';
import * as fs from 'fs';
import * as path from 'path';
@@ -13,26 +14,21 @@ import * as path from 'path';
* in a single session.
*/
export class WorkspaceContext {
private directories: Set<string>;
private directories = new Set<string>();
private initialDirectories: Set<string>;
/**
* Creates a new WorkspaceContext with the given initial directory and optional additional directories.
* @param initialDirectory The initial working directory (usually cwd)
* @param directory The initial working directory (usually cwd)
* @param additionalDirectories Optional array of additional directories to include
*/
constructor(initialDirectory: string, additionalDirectories: string[] = []) {
this.directories = new Set<string>();
this.initialDirectories = new Set<string>();
this.addDirectoryInternal(initialDirectory);
this.addInitialDirectoryInternal(initialDirectory);
for (const dir of additionalDirectories) {
this.addDirectoryInternal(dir);
this.addInitialDirectoryInternal(dir);
constructor(directory: string, additionalDirectories: string[] = []) {
this.addDirectory(directory);
for (const additionalDirectory of additionalDirectories) {
this.addDirectory(additionalDirectory);
}
this.initialDirectories = new Set(this.directories);
}
/**
@@ -41,16 +37,13 @@ export class WorkspaceContext {
* @param basePath Optional base path for resolving relative paths (defaults to cwd)
*/
addDirectory(directory: string, basePath: string = process.cwd()): void {
this.addDirectoryInternal(directory, basePath);
this.directories.add(this.resolveAndValidateDir(directory, basePath));
}
/**
* Internal method to add a directory with validation.
*/
private addDirectoryInternal(
private resolveAndValidateDir(
directory: string,
basePath: string = process.cwd(),
): void {
): string {
const absolutePath = path.isAbsolute(directory)
? directory
: path.resolve(basePath, directory);
@@ -58,47 +51,12 @@ export class WorkspaceContext {
if (!fs.existsSync(absolutePath)) {
throw new Error(`Directory does not exist: ${absolutePath}`);
}
const stats = fs.statSync(absolutePath);
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${absolutePath}`);
}
let realPath: string;
try {
realPath = fs.realpathSync(absolutePath);
} catch (_error) {
throw new Error(`Failed to resolve path: ${absolutePath}`);
}
this.directories.add(realPath);
}
private addInitialDirectoryInternal(
directory: string,
basePath: string = process.cwd(),
): void {
const absolutePath = path.isAbsolute(directory)
? directory
: path.resolve(basePath, directory);
if (!fs.existsSync(absolutePath)) {
throw new Error(`Directory does not exist: ${absolutePath}`);
}
const stats = fs.statSync(absolutePath);
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${absolutePath}`);
}
let realPath: string;
try {
realPath = fs.realpathSync(absolutePath);
} catch (_error) {
throw new Error(`Failed to resolve path: ${absolutePath}`);
}
this.initialDirectories.add(realPath);
return fs.realpathSync(absolutePath);
}
/**
@@ -116,7 +74,7 @@ export class WorkspaceContext {
setDirectories(directories: readonly string[]): void {
this.directories.clear();
for (const dir of directories) {
this.addDirectoryInternal(dir);
this.addDirectory(dir);
}
}
@@ -127,29 +85,43 @@ export class WorkspaceContext {
*/
isPathWithinWorkspace(pathToCheck: string): boolean {
try {
const absolutePath = path.resolve(pathToCheck);
let resolvedPath = absolutePath;
if (fs.existsSync(absolutePath)) {
try {
resolvedPath = fs.realpathSync(absolutePath);
} catch (_error) {
return false;
}
}
const fullyResolvedPath = this.fullyResolvedPath(pathToCheck);
for (const dir of this.directories) {
if (this.isPathWithinRoot(resolvedPath, dir)) {
if (this.isPathWithinRoot(fullyResolvedPath, dir)) {
return true;
}
}
return false;
} catch (_error) {
return false;
}
}
/**
* Fully resolves a path, including symbolic links.
* If the path does not exist, it returns the fully resolved path as it would be
* if it did exist.
*/
private fullyResolvedPath(pathToCheck: string): string {
try {
return fs.realpathSync(pathToCheck);
} catch (e: unknown) {
if (
isNodeError(e) &&
e.code === 'ENOENT' &&
e.path &&
// realpathSync does not set e.path correctly for symlinks to
// non-existent files.
!this.isFileSymlink(e.path)
) {
// If it doesn't exist, e.path contains the fully resolved path.
return e.path;
}
throw e;
}
}
/**
* Checks if a path is within a given root directory.
* @param pathToCheck The absolute path to check
@@ -167,4 +139,15 @@ export class WorkspaceContext {
!path.isAbsolute(relative)
);
}
/**
* Checks if a file path is a symbolic link that points to a file.
*/
private isFileSymlink(filePath: string): boolean {
try {
return !fs.readlinkSync(filePath).endsWith('/');
} catch (_error) {
return false;
}
}
}