mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
Merge branch 'main' into feature/shared-packages
This commit is contained in:
@@ -77,28 +77,36 @@ Path validation and security checks.
|
||||
```typescript
|
||||
import {
|
||||
initAllowedPaths,
|
||||
addAllowedPath,
|
||||
isPathAllowed,
|
||||
validatePath,
|
||||
getAllowedPaths
|
||||
getAllowedPaths,
|
||||
getAllowedRootDirectory,
|
||||
getDataDirectory,
|
||||
PathNotAllowedError
|
||||
} from '@automaker/platform';
|
||||
|
||||
// Initialize allowed paths from environment
|
||||
// Reads ALLOWED_ROOT_DIRECTORY and DATA_DIR environment variables
|
||||
initAllowedPaths();
|
||||
|
||||
// Add custom allowed path
|
||||
addAllowedPath('/custom/path');
|
||||
|
||||
// Check if path is allowed
|
||||
if (isPathAllowed('/project/path')) {
|
||||
console.log('Path is allowed');
|
||||
}
|
||||
|
||||
// Validate and normalize path
|
||||
const safePath = validatePath('/requested/path');
|
||||
// Validate and normalize path (throws PathNotAllowedError if not allowed)
|
||||
try {
|
||||
const safePath = validatePath('/requested/path');
|
||||
} catch (error) {
|
||||
if (error instanceof PathNotAllowedError) {
|
||||
console.error('Access denied:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all allowed paths
|
||||
const allowed = getAllowedPaths();
|
||||
// Get configured directories
|
||||
const rootDir = getAllowedRootDirectory(); // or null if not configured
|
||||
const dataDir = getDataDirectory(); // or null if not configured
|
||||
const allowed = getAllowedPaths(); // array of all allowed paths
|
||||
```
|
||||
|
||||
## Usage Example
|
||||
@@ -139,33 +147,44 @@ async function executeFeature(projectPath: string, featureId: string) {
|
||||
|
||||
## Security Model
|
||||
|
||||
**IMPORTANT: Path validation is currently disabled.**
|
||||
Path security is enforced through two environment variables:
|
||||
|
||||
All path access checks (`isPathAllowed()`) always return `true`, allowing unrestricted file system access. This is a deliberate design decision for the following reasons:
|
||||
### Environment Variables
|
||||
|
||||
### Rationale
|
||||
- **ALLOWED_ROOT_DIRECTORY**: Primary security boundary. When set, all file operations must be within this directory.
|
||||
- **DATA_DIR**: Application data directory (settings, credentials). Always allowed regardless of ALLOWED_ROOT_DIRECTORY.
|
||||
|
||||
1. **Development Flexibility**: AutoMaker is a development tool that needs to access various project directories chosen by the user. Strict path restrictions would limit its usefulness.
|
||||
### Behavior
|
||||
|
||||
2. **User Control**: The application runs with the user's permissions. Users should have full control over which directories they work with.
|
||||
1. **When ALLOWED_ROOT_DIRECTORY is set**: Only paths within this directory (or DATA_DIR) are allowed. Attempts to access other paths will throw `PathNotAllowedError`.
|
||||
|
||||
3. **Trust Model**: AutoMaker operates under a trust model where the user is assumed to be working on their own projects.
|
||||
2. **When ALLOWED_ROOT_DIRECTORY is not set**: All paths are allowed (backward compatibility mode).
|
||||
|
||||
### Implications
|
||||
3. **DATA_DIR exception**: Paths within DATA_DIR are always allowed, even if outside ALLOWED_ROOT_DIRECTORY. This ensures settings and credentials are always accessible.
|
||||
|
||||
- The allowed paths list is maintained for API compatibility but not enforced
|
||||
- All file system operations are performed with the user's full permissions
|
||||
- The tool does not impose artificial directory restrictions
|
||||
### Example Configuration
|
||||
|
||||
### Re-enabling Security (Future)
|
||||
```bash
|
||||
# Docker/containerized environment
|
||||
ALLOWED_ROOT_DIRECTORY=/workspace
|
||||
DATA_DIR=/app/data
|
||||
|
||||
If strict path validation is needed (e.g., for production deployments or untrusted environments):
|
||||
# Development (no restrictions)
|
||||
# Leave ALLOWED_ROOT_DIRECTORY unset for full access
|
||||
```
|
||||
|
||||
1. Modify `isPathAllowed()` in `src/security.ts` to check against the allowed paths list
|
||||
2. Consider adding an environment variable `ENABLE_PATH_SECURITY=true`
|
||||
3. Implement additional security layers as needed
|
||||
### Secure File System
|
||||
|
||||
The infrastructure is already in place; only the enforcement logic needs to be activated.
|
||||
The `secureFs` module wraps Node.js `fs` operations with path validation:
|
||||
|
||||
```typescript
|
||||
import { secureFs } from '@automaker/platform';
|
||||
|
||||
// All operations validate paths before execution
|
||||
await secureFs.readFile('/workspace/project/file.txt');
|
||||
await secureFs.writeFile('/workspace/project/output.txt', data);
|
||||
await secureFs.mkdir('/workspace/project/new-dir', { recursive: true });
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
|
||||
@@ -32,9 +32,15 @@ export {
|
||||
|
||||
// Security
|
||||
export {
|
||||
PathNotAllowedError,
|
||||
initAllowedPaths,
|
||||
addAllowedPath,
|
||||
isPathAllowed,
|
||||
validatePath,
|
||||
isPathWithinDirectory,
|
||||
getAllowedRootDirectory,
|
||||
getDataDirectory,
|
||||
getAllowedPaths,
|
||||
} from './security.js';
|
||||
|
||||
// Secure file system (validates paths before I/O operations)
|
||||
export * as secureFs from './secure-fs.js';
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* Directory creation is handled separately by ensure* functions.
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import * as secureFs from "./secure-fs.js";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
@@ -149,7 +149,7 @@ export function getBranchTrackingPath(projectPath: string): string {
|
||||
*/
|
||||
export async function ensureAutomakerDir(projectPath: string): Promise<string> {
|
||||
const automakerDir = getAutomakerDir(projectPath);
|
||||
await fs.mkdir(automakerDir, { recursive: true });
|
||||
await secureFs.mkdir(automakerDir, { recursive: true });
|
||||
return automakerDir;
|
||||
}
|
||||
|
||||
@@ -211,6 +211,6 @@ export function getProjectSettingsPath(projectPath: string): string {
|
||||
* @returns Promise resolving to the created data directory path
|
||||
*/
|
||||
export async function ensureDataDir(dataDir: string): Promise<string> {
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
await secureFs.mkdir(dataDir, { recursive: true });
|
||||
return dataDir;
|
||||
}
|
||||
|
||||
168
libs/platform/src/secure-fs.ts
Normal file
168
libs/platform/src/secure-fs.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Secure File System Adapter
|
||||
*
|
||||
* All file I/O operations must go through this adapter to enforce
|
||||
* ALLOWED_ROOT_DIRECTORY restrictions at the actual access point,
|
||||
* not just at the API layer. This provides defense-in-depth security.
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import type { Dirent } from "fs";
|
||||
import path from "path";
|
||||
import { validatePath } from "./security.js";
|
||||
|
||||
/**
|
||||
* Wrapper around fs.access that validates path first
|
||||
*/
|
||||
export async function access(filePath: string, mode?: number): Promise<void> {
|
||||
const validatedPath = validatePath(filePath);
|
||||
return fs.access(validatedPath, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around fs.readFile that validates path first
|
||||
*/
|
||||
export async function readFile(
|
||||
filePath: string,
|
||||
encoding?: BufferEncoding
|
||||
): Promise<string | Buffer> {
|
||||
const validatedPath = validatePath(filePath);
|
||||
if (encoding) {
|
||||
return fs.readFile(validatedPath, encoding);
|
||||
}
|
||||
return fs.readFile(validatedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around fs.writeFile that validates path first
|
||||
*/
|
||||
export async function writeFile(
|
||||
filePath: string,
|
||||
data: string | Buffer,
|
||||
encoding?: BufferEncoding
|
||||
): Promise<void> {
|
||||
const validatedPath = validatePath(filePath);
|
||||
return fs.writeFile(validatedPath, data, encoding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around fs.mkdir that validates path first
|
||||
*/
|
||||
export async function mkdir(
|
||||
dirPath: string,
|
||||
options?: { recursive?: boolean; mode?: number }
|
||||
): Promise<string | undefined> {
|
||||
const validatedPath = validatePath(dirPath);
|
||||
return fs.mkdir(validatedPath, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around fs.readdir that validates path first
|
||||
*/
|
||||
export async function readdir(
|
||||
dirPath: string,
|
||||
options?: { withFileTypes?: false; encoding?: BufferEncoding }
|
||||
): Promise<string[]>;
|
||||
export async function readdir(
|
||||
dirPath: string,
|
||||
options: { withFileTypes: true; encoding?: BufferEncoding }
|
||||
): Promise<Dirent[]>;
|
||||
export async function readdir(
|
||||
dirPath: string,
|
||||
options?: { withFileTypes?: boolean; encoding?: BufferEncoding }
|
||||
): Promise<string[] | Dirent[]> {
|
||||
const validatedPath = validatePath(dirPath);
|
||||
if (options?.withFileTypes === true) {
|
||||
return fs.readdir(validatedPath, { withFileTypes: true });
|
||||
}
|
||||
return fs.readdir(validatedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around fs.stat that validates path first
|
||||
*/
|
||||
export async function stat(filePath: string): Promise<any> {
|
||||
const validatedPath = validatePath(filePath);
|
||||
return fs.stat(validatedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around fs.rm that validates path first
|
||||
*/
|
||||
export async function rm(
|
||||
filePath: string,
|
||||
options?: { recursive?: boolean; force?: boolean }
|
||||
): Promise<void> {
|
||||
const validatedPath = validatePath(filePath);
|
||||
return fs.rm(validatedPath, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around fs.unlink that validates path first
|
||||
*/
|
||||
export async function unlink(filePath: string): Promise<void> {
|
||||
const validatedPath = validatePath(filePath);
|
||||
return fs.unlink(validatedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around fs.copyFile that validates both paths first
|
||||
*/
|
||||
export async function copyFile(
|
||||
src: string,
|
||||
dest: string,
|
||||
mode?: number
|
||||
): Promise<void> {
|
||||
const validatedSrc = validatePath(src);
|
||||
const validatedDest = validatePath(dest);
|
||||
return fs.copyFile(validatedSrc, validatedDest, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around fs.appendFile that validates path first
|
||||
*/
|
||||
export async function appendFile(
|
||||
filePath: string,
|
||||
data: string | Buffer,
|
||||
encoding?: BufferEncoding
|
||||
): Promise<void> {
|
||||
const validatedPath = validatePath(filePath);
|
||||
return fs.appendFile(validatedPath, data, encoding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around fs.rename that validates both paths first
|
||||
*/
|
||||
export async function rename(
|
||||
oldPath: string,
|
||||
newPath: string
|
||||
): Promise<void> {
|
||||
const validatedOldPath = validatePath(oldPath);
|
||||
const validatedNewPath = validatePath(newPath);
|
||||
return fs.rename(validatedOldPath, validatedNewPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around fs.lstat that validates path first
|
||||
* Returns file stats without following symbolic links
|
||||
*/
|
||||
export async function lstat(filePath: string): Promise<any> {
|
||||
const validatedPath = validatePath(filePath);
|
||||
return fs.lstat(validatedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around path.join that returns resolved path
|
||||
* Does NOT validate - use this for path construction, then pass to other operations
|
||||
*/
|
||||
export function joinPath(...pathSegments: string[]): string {
|
||||
return path.join(...pathSegments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around path.resolve that returns resolved path
|
||||
* Does NOT validate - use this for path construction, then pass to other operations
|
||||
*/
|
||||
export function resolvePath(...pathSegments: string[]): string {
|
||||
return path.resolve(...pathSegments);
|
||||
}
|
||||
@@ -1,90 +1,143 @@
|
||||
/**
|
||||
* Security utilities for path validation
|
||||
*
|
||||
* SECURITY NOTICE: Path validation is currently DISABLED
|
||||
*
|
||||
* All path access checks always return true, allowing unrestricted file system access.
|
||||
* This was a deliberate design decision for the following reasons:
|
||||
*
|
||||
* 1. Development Flexibility: AutoMaker is a development tool that needs to access
|
||||
* various project directories chosen by the user. Strict path restrictions would
|
||||
* limit its usefulness.
|
||||
*
|
||||
* 2. User Control: The application runs with the user's permissions. Users should
|
||||
* have full control over which directories they work with, without artificial
|
||||
* restrictions imposed by the tool.
|
||||
*
|
||||
* 3. Trust Model: AutoMaker operates under a trust model where the user is assumed
|
||||
* to be working on their own projects. The tool itself doesn't perform operations
|
||||
* without user initiation.
|
||||
*
|
||||
* SECURITY CONSIDERATIONS:
|
||||
* - This module maintains the allowed paths list for API compatibility and potential
|
||||
* future use, but does not enforce any restrictions.
|
||||
* - If security restrictions are needed in the future, the infrastructure is in place
|
||||
* to enable them by modifying isPathAllowed() to actually check the allowed list.
|
||||
* - For production deployments or untrusted environments, consider re-enabling path
|
||||
* validation or implementing additional security layers.
|
||||
*
|
||||
* FUTURE ENHANCEMENT: Consider adding an environment variable (e.g., ENABLE_PATH_SECURITY)
|
||||
* to allow enabling strict path validation when needed for specific deployment scenarios.
|
||||
* Enforces ALLOWED_ROOT_DIRECTORY constraint with appData exception
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
|
||||
// Allowed project directories - kept for API compatibility
|
||||
const allowedPaths = new Set<string>();
|
||||
/**
|
||||
* Error thrown when a path is not allowed by security policy
|
||||
*/
|
||||
export class PathNotAllowedError extends Error {
|
||||
constructor(filePath: string) {
|
||||
super(
|
||||
`Path not allowed: ${filePath}. Must be within ALLOWED_ROOT_DIRECTORY or DATA_DIR.`
|
||||
);
|
||||
this.name = "PathNotAllowedError";
|
||||
}
|
||||
}
|
||||
|
||||
// Allowed root directory - main security boundary
|
||||
let allowedRootDirectory: string | null = null;
|
||||
|
||||
// Data directory - always allowed for settings/credentials
|
||||
let dataDirectory: string | null = null;
|
||||
|
||||
/**
|
||||
* Initialize allowed paths from environment variable
|
||||
* Note: All paths are now allowed regardless of this setting
|
||||
* Initialize security settings from environment variables
|
||||
* - ALLOWED_ROOT_DIRECTORY: main security boundary
|
||||
* - DATA_DIR: appData exception, always allowed
|
||||
*/
|
||||
export function initAllowedPaths(): void {
|
||||
const dirs = process.env.ALLOWED_PROJECT_DIRS;
|
||||
if (dirs) {
|
||||
for (const dir of dirs.split(",")) {
|
||||
const trimmed = dir.trim();
|
||||
if (trimmed) {
|
||||
allowedPaths.add(path.resolve(trimmed));
|
||||
}
|
||||
}
|
||||
// Load ALLOWED_ROOT_DIRECTORY
|
||||
const rootDir = process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
if (rootDir) {
|
||||
allowedRootDirectory = path.resolve(rootDir);
|
||||
console.log(
|
||||
`[Security] ✓ ALLOWED_ROOT_DIRECTORY configured: ${allowedRootDirectory}`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
"[Security] ⚠️ ALLOWED_ROOT_DIRECTORY not set - allowing access to all paths"
|
||||
);
|
||||
}
|
||||
|
||||
// Load DATA_DIR (appData exception - always allowed)
|
||||
const dataDir = process.env.DATA_DIR;
|
||||
if (dataDir) {
|
||||
allowedPaths.add(path.resolve(dataDir));
|
||||
}
|
||||
|
||||
const workspaceDir = process.env.WORKSPACE_DIR;
|
||||
if (workspaceDir) {
|
||||
allowedPaths.add(path.resolve(workspaceDir));
|
||||
dataDirectory = path.resolve(dataDir);
|
||||
console.log(`[Security] ✓ DATA_DIR configured: ${dataDirectory}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a path to the allowed list (no-op, all paths allowed)
|
||||
* Check if a path is allowed based on ALLOWED_ROOT_DIRECTORY
|
||||
* Returns true if:
|
||||
* - Path is within ALLOWED_ROOT_DIRECTORY, OR
|
||||
* - Path is within DATA_DIR (appData exception), OR
|
||||
* - No restrictions are configured (backward compatibility)
|
||||
*/
|
||||
export function addAllowedPath(filePath: string): void {
|
||||
allowedPaths.add(path.resolve(filePath));
|
||||
export function isPathAllowed(filePath: string): boolean {
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
// Always allow appData directory (settings, credentials)
|
||||
if (dataDirectory && isPathWithinDirectory(resolvedPath, dataDirectory)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no ALLOWED_ROOT_DIRECTORY restriction is configured, allow all paths
|
||||
// Note: DATA_DIR is checked above as an exception, but doesn't restrict other paths
|
||||
if (!allowedRootDirectory) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow if within ALLOWED_ROOT_DIRECTORY
|
||||
if (
|
||||
allowedRootDirectory &&
|
||||
isPathWithinDirectory(resolvedPath, allowedRootDirectory)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If restrictions are configured but path doesn't match, deny
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is allowed - always returns true
|
||||
*/
|
||||
export function isPathAllowed(_filePath: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a path - just resolves the path without checking permissions
|
||||
* Validate a path - resolves it and checks permissions
|
||||
* Throws PathNotAllowedError if path is not allowed
|
||||
*/
|
||||
export function validatePath(filePath: string): string {
|
||||
return path.resolve(filePath);
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
if (!isPathAllowed(resolvedPath)) {
|
||||
throw new PathNotAllowedError(filePath);
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is within a directory, with protection against path traversal
|
||||
* Returns true only if resolvedPath is within directoryPath
|
||||
*/
|
||||
export function isPathWithinDirectory(
|
||||
resolvedPath: string,
|
||||
directoryPath: string
|
||||
): boolean {
|
||||
// Get the relative path from directory to the target
|
||||
const relativePath = path.relative(directoryPath, resolvedPath);
|
||||
|
||||
// If relative path starts with "..", it's outside the directory
|
||||
// If relative path is absolute, it's outside the directory
|
||||
// If relative path is empty or ".", it's the directory itself
|
||||
return !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured allowed root directory
|
||||
*/
|
||||
export function getAllowedRootDirectory(): string | null {
|
||||
return allowedRootDirectory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured data directory
|
||||
*/
|
||||
export function getDataDirectory(): string | null {
|
||||
return dataDirectory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of allowed paths (for debugging)
|
||||
*/
|
||||
export function getAllowedPaths(): string[] {
|
||||
return Array.from(allowedPaths);
|
||||
const paths: string[] = [];
|
||||
if (allowedRootDirectory) {
|
||||
paths.push(allowedRootDirectory);
|
||||
}
|
||||
if (dataDirectory) {
|
||||
paths.push(dataDirectory);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import path from "path";
|
||||
import {
|
||||
initAllowedPaths,
|
||||
addAllowedPath,
|
||||
isPathAllowed,
|
||||
validatePath,
|
||||
getAllowedPaths,
|
||||
} from "../src/security";
|
||||
|
||||
describe("security.ts", () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
@@ -14,6 +7,8 @@ describe("security.ts", () => {
|
||||
beforeEach(() => {
|
||||
// Save original environment
|
||||
originalEnv = { ...process.env };
|
||||
// Reset modules to get fresh state
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -22,217 +17,237 @@ describe("security.ts", () => {
|
||||
});
|
||||
|
||||
describe("initAllowedPaths", () => {
|
||||
it("should initialize from ALLOWED_PROJECT_DIRS environment variable", () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/path/one,/path/two,/path/three";
|
||||
it("should load ALLOWED_ROOT_DIRECTORY if set", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } =
|
||||
await import("../src/security");
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/path/one"));
|
||||
expect(allowed).toContain(path.resolve("/path/two"));
|
||||
expect(allowed).toContain(path.resolve("/path/three"));
|
||||
expect(allowed).toContain(path.resolve("/projects"));
|
||||
});
|
||||
|
||||
it("should trim whitespace from paths", () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = " /path/one , /path/two , /path/three ";
|
||||
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/path/one"));
|
||||
expect(allowed).toContain(path.resolve("/path/two"));
|
||||
expect(allowed).toContain(path.resolve("/path/three"));
|
||||
});
|
||||
|
||||
it("should skip empty paths", () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/path/one,,/path/two, ,/path/three";
|
||||
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed.length).toBeLessThanOrEqual(3);
|
||||
expect(allowed).toContain(path.resolve("/path/one"));
|
||||
});
|
||||
|
||||
it("should initialize from DATA_DIR environment variable", () => {
|
||||
it("should load DATA_DIR if set", async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
process.env.DATA_DIR = "/data/directory";
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } =
|
||||
await import("../src/security");
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/data/directory"));
|
||||
});
|
||||
|
||||
it("should initialize from WORKSPACE_DIR environment variable", () => {
|
||||
process.env.WORKSPACE_DIR = "/workspace/directory";
|
||||
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/workspace/directory"));
|
||||
});
|
||||
|
||||
it("should handle all environment variables together", () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/projects/one,/projects/two";
|
||||
it("should load both ALLOWED_ROOT_DIRECTORY and DATA_DIR if both set", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
|
||||
process.env.DATA_DIR = "/app/data";
|
||||
process.env.WORKSPACE_DIR = "/app/workspace";
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } =
|
||||
await import("../src/security");
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/projects/one"));
|
||||
expect(allowed).toContain(path.resolve("/projects/two"));
|
||||
expect(allowed).toContain(path.resolve("/projects"));
|
||||
expect(allowed).toContain(path.resolve("/app/data"));
|
||||
expect(allowed).toContain(path.resolve("/app/workspace"));
|
||||
});
|
||||
|
||||
it("should handle missing environment variables gracefully", () => {
|
||||
delete process.env.ALLOWED_PROJECT_DIRS;
|
||||
it("should handle missing environment variables gracefully", async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.WORKSPACE_DIR;
|
||||
|
||||
const { initAllowedPaths } = await import("../src/security");
|
||||
expect(() => initAllowedPaths()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addAllowedPath", () => {
|
||||
it("should add a path to allowed list", () => {
|
||||
const testPath = "/new/allowed/path";
|
||||
|
||||
addAllowedPath(testPath);
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve(testPath));
|
||||
});
|
||||
|
||||
it("should resolve relative paths to absolute", () => {
|
||||
const relativePath = "relative/path";
|
||||
|
||||
addAllowedPath(relativePath);
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve(relativePath));
|
||||
});
|
||||
|
||||
it("should handle duplicate paths", () => {
|
||||
const testPath = "/duplicate/path";
|
||||
|
||||
addAllowedPath(testPath);
|
||||
addAllowedPath(testPath);
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
const count = allowed.filter((p) => p === path.resolve(testPath)).length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPathAllowed", () => {
|
||||
it("should always return true (all paths allowed)", () => {
|
||||
it("should allow paths within ALLOWED_ROOT_DIRECTORY", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } =
|
||||
await import("../src/security");
|
||||
initAllowedPaths();
|
||||
|
||||
expect(isPathAllowed("/allowed/file.txt")).toBe(true);
|
||||
expect(isPathAllowed("/allowed/subdir/file.txt")).toBe(true);
|
||||
});
|
||||
|
||||
it("should deny paths outside ALLOWED_ROOT_DIRECTORY", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } =
|
||||
await import("../src/security");
|
||||
initAllowedPaths();
|
||||
|
||||
expect(isPathAllowed("/not-allowed/file.txt")).toBe(false);
|
||||
expect(isPathAllowed("/etc/passwd")).toBe(false);
|
||||
});
|
||||
|
||||
it("should always allow DATA_DIR paths", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
|
||||
process.env.DATA_DIR = "/app/data";
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } =
|
||||
await import("../src/security");
|
||||
initAllowedPaths();
|
||||
|
||||
// DATA_DIR paths are always allowed
|
||||
expect(isPathAllowed("/app/data/settings.json")).toBe(true);
|
||||
expect(isPathAllowed("/app/data/credentials.json")).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow all paths when no restrictions configured", async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } =
|
||||
await import("../src/security");
|
||||
initAllowedPaths();
|
||||
|
||||
expect(isPathAllowed("/any/path")).toBe(true);
|
||||
expect(isPathAllowed("/another/path")).toBe(true);
|
||||
expect(isPathAllowed("relative/path")).toBe(true);
|
||||
expect(isPathAllowed("/etc/passwd")).toBe(true);
|
||||
expect(isPathAllowed("../../../dangerous/path")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true even for non-existent paths", () => {
|
||||
expect(isPathAllowed("/nonexistent/path/12345")).toBe(true);
|
||||
});
|
||||
it("should allow all paths when only DATA_DIR is configured", async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
process.env.DATA_DIR = "/data";
|
||||
|
||||
it("should return true for empty string", () => {
|
||||
expect(isPathAllowed("")).toBe(true);
|
||||
const { initAllowedPaths, isPathAllowed } =
|
||||
await import("../src/security");
|
||||
initAllowedPaths();
|
||||
|
||||
// DATA_DIR should be allowed
|
||||
expect(isPathAllowed("/data/file.txt")).toBe(true);
|
||||
// And all other paths should be allowed since no ALLOWED_ROOT_DIRECTORY restriction
|
||||
expect(isPathAllowed("/any/path")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePath", () => {
|
||||
it("should resolve absolute paths", () => {
|
||||
const absPath = "/absolute/path/to/file.txt";
|
||||
const result = validatePath(absPath);
|
||||
expect(result).toBe(path.resolve(absPath));
|
||||
it("should return resolved path for allowed paths", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, validatePath } =
|
||||
await import("../src/security");
|
||||
initAllowedPaths();
|
||||
|
||||
const result = validatePath("/allowed/file.txt");
|
||||
expect(result).toBe(path.resolve("/allowed/file.txt"));
|
||||
});
|
||||
|
||||
it("should resolve relative paths", () => {
|
||||
const relPath = "relative/path/file.txt";
|
||||
const result = validatePath(relPath);
|
||||
expect(result).toBe(path.resolve(relPath));
|
||||
it("should throw error for paths outside allowed directories", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, validatePath, PathNotAllowedError } =
|
||||
await import("../src/security");
|
||||
initAllowedPaths();
|
||||
|
||||
expect(() => validatePath("/not-allowed/file.txt")).toThrow(
|
||||
PathNotAllowedError
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle current directory", () => {
|
||||
const result = validatePath(".");
|
||||
expect(result).toBe(path.resolve("."));
|
||||
it("should resolve relative paths", async () => {
|
||||
const cwd = process.cwd();
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = cwd;
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, validatePath } =
|
||||
await import("../src/security");
|
||||
initAllowedPaths();
|
||||
|
||||
const result = validatePath("./file.txt");
|
||||
expect(result).toBe(path.resolve(cwd, "./file.txt"));
|
||||
});
|
||||
|
||||
it("should handle parent directory", () => {
|
||||
const result = validatePath("..");
|
||||
expect(result).toBe(path.resolve(".."));
|
||||
});
|
||||
it("should not throw when no restrictions configured", async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
it("should handle complex relative paths", () => {
|
||||
const complexPath = "../../some/nested/../path/./file.txt";
|
||||
const result = validatePath(complexPath);
|
||||
expect(result).toBe(path.resolve(complexPath));
|
||||
});
|
||||
const { initAllowedPaths, validatePath } =
|
||||
await import("../src/security");
|
||||
initAllowedPaths();
|
||||
|
||||
it("should handle paths with spaces", () => {
|
||||
const pathWithSpaces = "/path with spaces/file.txt";
|
||||
const result = validatePath(pathWithSpaces);
|
||||
expect(result).toBe(path.resolve(pathWithSpaces));
|
||||
});
|
||||
|
||||
it("should handle home directory expansion on Unix", () => {
|
||||
if (process.platform !== "win32") {
|
||||
const homePath = "~/documents/file.txt";
|
||||
const result = validatePath(homePath);
|
||||
expect(result).toBe(path.resolve(homePath));
|
||||
}
|
||||
expect(() => validatePath("/any/path")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllowedPaths", () => {
|
||||
it("should return empty array initially", () => {
|
||||
it("should return empty array when no paths configured", async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } =
|
||||
await import("../src/security");
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(Array.isArray(allowed)).toBe(true);
|
||||
expect(allowed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should return array of added paths", () => {
|
||||
addAllowedPath("/path/one");
|
||||
addAllowedPath("/path/two");
|
||||
it("should return configured paths", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
|
||||
process.env.DATA_DIR = "/data";
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } =
|
||||
await import("../src/security");
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/path/one"));
|
||||
expect(allowed).toContain(path.resolve("/path/two"));
|
||||
});
|
||||
|
||||
it("should return copy of internal set", () => {
|
||||
addAllowedPath("/test/path");
|
||||
|
||||
const allowed1 = getAllowedPaths();
|
||||
const allowed2 = getAllowedPaths();
|
||||
|
||||
expect(allowed1).not.toBe(allowed2);
|
||||
expect(allowed1).toEqual(allowed2);
|
||||
expect(allowed).toContain(path.resolve("/projects"));
|
||||
expect(allowed).toContain(path.resolve("/data"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Path security disabled behavior", () => {
|
||||
it("should allow unrestricted access despite allowed paths list", () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/only/this/path";
|
||||
describe("getAllowedRootDirectory", () => {
|
||||
it("should return the configured root directory", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
|
||||
|
||||
const { initAllowedPaths, getAllowedRootDirectory } =
|
||||
await import("../src/security");
|
||||
initAllowedPaths();
|
||||
|
||||
// Should return true even for paths not in allowed list
|
||||
expect(isPathAllowed("/some/other/path")).toBe(true);
|
||||
expect(isPathAllowed("/completely/different/path")).toBe(true);
|
||||
expect(getAllowedRootDirectory()).toBe(path.resolve("/projects"));
|
||||
});
|
||||
|
||||
it("should validate paths without permission checks", () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/only/this/path";
|
||||
it("should return null when not configured", async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
|
||||
const { initAllowedPaths, getAllowedRootDirectory } =
|
||||
await import("../src/security");
|
||||
initAllowedPaths();
|
||||
|
||||
// Should validate any path without throwing
|
||||
expect(() => validatePath("/some/other/path")).not.toThrow();
|
||||
expect(validatePath("/some/other/path")).toBe(
|
||||
path.resolve("/some/other/path")
|
||||
);
|
||||
expect(getAllowedRootDirectory()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDataDirectory", () => {
|
||||
it("should return the configured data directory", async () => {
|
||||
process.env.DATA_DIR = "/data";
|
||||
|
||||
const { initAllowedPaths, getDataDirectory } =
|
||||
await import("../src/security");
|
||||
initAllowedPaths();
|
||||
|
||||
expect(getDataDirectory()).toBe(path.resolve("/data"));
|
||||
});
|
||||
|
||||
it("should return null when not configured", async () => {
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, getDataDirectory } =
|
||||
await import("../src/security");
|
||||
initAllowedPaths();
|
||||
|
||||
expect(getDataDirectory()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user