Skip to content
Merged
Show file tree
Hide file tree
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
2 changes: 1 addition & 1 deletion src/api/apiMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export class ApiMachineClient {
logger: (msg, data) => logger.debug(msg, data)
});

registerCommonHandlers(this.rpcHandlerManager);
registerCommonHandlers(this.rpcHandlerManager, process.cwd());
}

setRPCHandlers({
Expand Down
2 changes: 1 addition & 1 deletion src/api/apiSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export class ApiSessionClient extends EventEmitter {
encryptionVariant: this.encryptionVariant,
logger: (msg, data) => logger.debug(msg, data)
});
registerCommonHandlers(this.rpcHandlerManager);
registerCommonHandlers(this.rpcHandlerManager, this.metadata.path);

//
// Create socket
Expand Down
29 changes: 29 additions & 0 deletions src/modules/common/pathSecurity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { validatePath } from './pathSecurity';

describe('validatePath', () => {
const workingDir = '/home/user/project';

it('should allow paths within working directory', () => {
expect(validatePath('/home/user/project/file.txt', workingDir).valid).toBe(true);
expect(validatePath('file.txt', workingDir).valid).toBe(true);
expect(validatePath('./src/file.txt', workingDir).valid).toBe(true);
});

it('should reject paths outside working directory', () => {
const result = validatePath('/etc/passwd', workingDir);
expect(result.valid).toBe(false);
expect(result.error).toContain('outside the working directory');
});

it('should prevent path traversal attacks', () => {
const result = validatePath('../../.ssh/id_rsa', workingDir);
expect(result.valid).toBe(false);
expect(result.error).toContain('outside the working directory');
});

it('should allow the working directory itself', () => {
expect(validatePath('.', workingDir).valid).toBe(true);
expect(validatePath(workingDir, workingDir).valid).toBe(true);
});
});
29 changes: 29 additions & 0 deletions src/modules/common/pathSecurity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { resolve } from 'path';

export interface PathValidationResult {
valid: boolean;
error?: string;
}

/**
* Validates that a path is within the allowed working directory
* @param targetPath - The path to validate (can be relative or absolute)
* @param workingDirectory - The session's working directory (must be absolute)
* @returns Validation result
*/
export function validatePath(targetPath: string, workingDirectory: string): PathValidationResult {
// Resolve both paths to absolute paths to handle path traversal attempts
const resolvedTarget = resolve(workingDirectory, targetPath);
const resolvedWorkingDir = resolve(workingDirectory);

// Check if the resolved target path starts with the working directory
// This prevents access to files outside the working directory
if (!resolvedTarget.startsWith(resolvedWorkingDir + '/') && resolvedTarget !== resolvedWorkingDir) {
return {
valid: false,
error: `Access denied: Path '${targetPath}' is outside the working directory`
};
}

return { valid: true };
}
51 changes: 50 additions & 1 deletion src/modules/common/registerCommonHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { join } from 'path';
import { run as runRipgrep } from '@/modules/ripgrep/index';
import { run as runDifftastic } from '@/modules/difftastic/index';
import { RpcHandlerManager } from '../../api/rpc/RpcHandlerManager';
import { validatePath } from './pathSecurity';

const execAsync = promisify(exec);

Expand Down Expand Up @@ -131,12 +132,20 @@ export type SpawnSessionResult =
/**
* Register all RPC handlers with the session
*/
export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager) {
export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string) {

// Shell command handler - executes commands in the default shell
rpcHandlerManager.registerHandler<BashRequest, BashResponse>('bash', async (data) => {
logger.debug('Shell command request:', data.command);

// Validate cwd if provided
if (data.cwd) {
const validation = validatePath(data.cwd, workingDirectory);
if (!validation.valid) {
return { success: false, error: validation.error };
}
}

try {
// Build options with shell enabled by default
// Note: ExecOptions doesn't support boolean for shell, but exec() uses the default shell when shell is undefined
Expand Down Expand Up @@ -187,6 +196,12 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager) {
rpcHandlerManager.registerHandler<ReadFileRequest, ReadFileResponse>('readFile', async (data) => {
logger.debug('Read file request:', data.path);

// Validate path is within working directory
const validation = validatePath(data.path, workingDirectory);
if (!validation.valid) {
return { success: false, error: validation.error };
}

try {
const buffer = await readFile(data.path);
const content = buffer.toString('base64');
Expand All @@ -201,6 +216,12 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager) {
rpcHandlerManager.registerHandler<WriteFileRequest, WriteFileResponse>('writeFile', async (data) => {
logger.debug('Write file request:', data.path);

// Validate path is within working directory
const validation = validatePath(data.path, workingDirectory);
if (!validation.valid) {
return { success: false, error: validation.error };
}

try {
// If expectedHash is provided (not null), verify existing file
if (data.expectedHash !== null && data.expectedHash !== undefined) {
Expand Down Expand Up @@ -261,6 +282,12 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager) {
rpcHandlerManager.registerHandler<ListDirectoryRequest, ListDirectoryResponse>('listDirectory', async (data) => {
logger.debug('List directory request:', data.path);

// Validate path is within working directory
const validation = validatePath(data.path, workingDirectory);
if (!validation.valid) {
return { success: false, error: validation.error };
}

try {
const entries = await readdir(data.path, { withFileTypes: true });

Expand Down Expand Up @@ -313,6 +340,12 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager) {
rpcHandlerManager.registerHandler<GetDirectoryTreeRequest, GetDirectoryTreeResponse>('getDirectoryTree', async (data) => {
logger.debug('Get directory tree request:', data.path, 'maxDepth:', data.maxDepth);

// Validate path is within working directory
const validation = validatePath(data.path, workingDirectory);
if (!validation.valid) {
return { success: false, error: validation.error };
}

// Helper function to build tree recursively
async function buildTree(path: string, name: string, currentDepth: number): Promise<TreeNode | null> {
try {
Expand Down Expand Up @@ -394,6 +427,14 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager) {
rpcHandlerManager.registerHandler<RipgrepRequest, RipgrepResponse>('ripgrep', async (data) => {
logger.debug('Ripgrep request with args:', data.args, 'cwd:', data.cwd);

// Validate cwd if provided
if (data.cwd) {
const validation = validatePath(data.cwd, workingDirectory);
if (!validation.valid) {
return { success: false, error: validation.error };
}
}

try {
const result = await runRipgrep(data.args, { cwd: data.cwd });
return {
Expand All @@ -415,6 +456,14 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager) {
rpcHandlerManager.registerHandler<DifftasticRequest, DifftasticResponse>('difftastic', async (data) => {
logger.debug('Difftastic request with args:', data.args, 'cwd:', data.cwd);

// Validate cwd if provided
if (data.cwd) {
const validation = validatePath(data.cwd, workingDirectory);
if (!validation.valid) {
return { success: false, error: validation.error };
}
}

try {
const result = await runDifftastic(data.args, { cwd: data.cwd });
return {
Expand Down