Skip to content

Commit 01b4b6e

Browse files
committed
strict directories validation
1 parent e217ad8 commit 01b4b6e

File tree

3 files changed

+153
-29
lines changed

3 files changed

+153
-29
lines changed

codeflash/cli_cmds/cmd_init.py

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from codeflash.code_utils.github_utils import get_github_secrets_page_url
3434
from codeflash.code_utils.oauth_handler import perform_oauth_signin
3535
from codeflash.code_utils.shell_utils import get_shell_rc_path, is_powershell, save_api_key_to_rc
36+
from codeflash.code_utils.code_utils import validate_relative_directory_path
3637
from codeflash.either import is_successful
3738
from codeflash.lsp.helpers import is_LSP_enabled
3839
from codeflash.telemetry.posthog_cf import ph
@@ -356,20 +357,32 @@ def collect_setup_info() -> CLISetupInfo:
356357
console.print(custom_panel)
357358
console.print()
358359

359-
custom_questions = [
360-
inquirer.Path(
361-
"custom_path",
362-
message="Enter the path to your module directory",
363-
path_type=inquirer.Path.DIRECTORY,
364-
exists=True,
365-
)
366-
]
360+
# Retry loop for custom module root path
361+
module_root = None
362+
while module_root is None:
363+
custom_questions = [
364+
inquirer.Path(
365+
"custom_path",
366+
message="Enter the path to your module directory",
367+
path_type=inquirer.Path.DIRECTORY,
368+
exists=True,
369+
)
370+
]
367371

368-
custom_answers = inquirer.prompt(custom_questions, theme=CodeflashTheme())
369-
if custom_answers:
370-
module_root = Path(custom_answers["custom_path"])
371-
else:
372-
apologize_and_exit()
372+
custom_answers = inquirer.prompt(custom_questions, theme=CodeflashTheme())
373+
if not custom_answers:
374+
apologize_and_exit()
375+
return # unreachable but satisfies type checker
376+
377+
custom_path_str = str(custom_answers["custom_path"])
378+
# Validate the path is safe
379+
is_valid, error_msg = validate_relative_directory_path(custom_path_str)
380+
if not is_valid:
381+
click.echo(f"❌ Invalid path: {error_msg}")
382+
click.echo("Please enter a valid relative directory path.")
383+
console.print() # Add spacing before retry
384+
continue # Retry the prompt
385+
module_root = Path(custom_path_str)
373386
else:
374387
module_root = module_root_answer
375388
ph("cli-project-root-provided")
@@ -427,20 +440,32 @@ def collect_setup_info() -> CLISetupInfo:
427440
console.print(custom_tests_panel)
428441
console.print()
429442

430-
custom_tests_questions = [
431-
inquirer.Path(
432-
"custom_tests_path",
433-
message="Enter the path to your tests directory",
434-
path_type=inquirer.Path.DIRECTORY,
435-
exists=True,
436-
)
437-
]
443+
# Retry loop for custom tests root path
444+
tests_root = None
445+
while tests_root is None:
446+
custom_tests_questions = [
447+
inquirer.Path(
448+
"custom_tests_path",
449+
message="Enter the path to your tests directory",
450+
path_type=inquirer.Path.DIRECTORY,
451+
exists=True,
452+
)
453+
]
438454

439-
custom_tests_answers = inquirer.prompt(custom_tests_questions, theme=CodeflashTheme())
440-
if custom_tests_answers:
441-
tests_root = Path(curdir) / Path(custom_tests_answers["custom_tests_path"])
442-
else:
443-
apologize_and_exit()
455+
custom_tests_answers = inquirer.prompt(custom_tests_questions, theme=CodeflashTheme())
456+
if not custom_tests_answers:
457+
apologize_and_exit()
458+
return # unreachable but satisfies type checker
459+
460+
custom_tests_path_str = str(custom_tests_answers["custom_tests_path"])
461+
# Validate the path is safe
462+
is_valid, error_msg = validate_relative_directory_path(custom_tests_path_str)
463+
if not is_valid:
464+
click.echo(f"❌ Invalid path: {error_msg}")
465+
click.echo("Please enter a valid relative directory path.")
466+
console.print() # Add spacing before retry
467+
continue # Retry the prompt
468+
tests_root = Path(curdir) / Path(custom_tests_path_str)
444469
else:
445470
tests_root = Path(curdir) / Path(cast("str", tests_root_answer))
446471

codeflash/code_utils/code_utils.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,3 +376,83 @@ def extract_unique_errors(pytest_output: str) -> set[str]:
376376
unique_errors.add(error_message)
377377

378378
return unique_errors
379+
380+
381+
def validate_relative_directory_path(path: str) -> tuple[bool, str]:
382+
"""Validate that a path is a safe relative directory path.
383+
384+
Prevents path traversal attacks and invalid paths.
385+
Works cross-platform (Windows, Linux, macOS).
386+
387+
Args:
388+
path: The path string to validate
389+
390+
Returns:
391+
tuple[bool, str]: (is_valid, error_message)
392+
- is_valid: True if path is valid, False otherwise
393+
- error_message: Empty string if valid, error description if invalid
394+
"""
395+
if not path or not path.strip():
396+
return False, "Path cannot be empty"
397+
398+
# Normalize whitespace
399+
path = path.strip()
400+
401+
# Check for shell commands or dangerous patterns
402+
dangerous_patterns = [
403+
"cd ",
404+
"ls ",
405+
"rm ",
406+
"mkdir ",
407+
"rmdir ",
408+
"del ",
409+
"dir ",
410+
"type ",
411+
"cat ",
412+
"echo ",
413+
"&&",
414+
"||",
415+
";",
416+
"|",
417+
">",
418+
"<",
419+
"$",
420+
"`",
421+
]
422+
path_lower = path.lower()
423+
for pattern in dangerous_patterns:
424+
if pattern in path_lower:
425+
return False, f"Path contains invalid characters or commands: {pattern.strip()}"
426+
427+
# Check for path traversal attempts (cross-platform)
428+
# Normalize path separators for checking
429+
normalized = path.replace("\\", "/")
430+
if ".." in normalized:
431+
return False, "Path cannot contain '..' (parent directory traversal)"
432+
433+
# Check for absolute paths (Windows and Unix)
434+
if os.path.isabs(path):
435+
return False, "Path must be relative, not absolute"
436+
437+
# Check for invalid characters (OS-specific)
438+
invalid_chars = set()
439+
if os.name == "nt": # Windows
440+
invalid_chars = {'<', '>', ':', '"', '|', '?', '*'}
441+
else: # Unix-like
442+
invalid_chars = {'\0'}
443+
444+
if any(char in path for char in invalid_chars):
445+
return False, f"Path contains invalid characters for this operating system"
446+
447+
# Validate using pathlib to ensure it's a valid path structure
448+
try:
449+
path_obj = Path(path)
450+
# Check if path would resolve outside the current directory
451+
# This is a safety check for edge cases
452+
parts = path_obj.parts
453+
if any(part == ".." for part in parts):
454+
return False, "Path cannot contain '..' (parent directory traversal)"
455+
except (ValueError, OSError) as e:
456+
return False, f"Invalid path format: {str(e)}"
457+
458+
return True, ""

codeflash/lsp/beta.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
get_valid_subdirs,
2424
is_valid_pyproject_toml,
2525
)
26+
from codeflash.code_utils.code_utils import validate_relative_directory_path
2627
from codeflash.code_utils.git_utils import git_root_dir
2728
from codeflash.code_utils.git_worktree_utils import create_worktree_snapshot_commit
2829
from codeflash.code_utils.shell_utils import save_api_key_to_rc
@@ -191,9 +192,16 @@ def get_config_value(key: str, default: str = "") -> str:
191192
return getattr(cfg, key, default)
192193

193194
tests_root = get_config_value("tests_root", "")
194-
# Validate tests_root directory exists if provided
195+
# Validate tests_root path format and safety
195196
if tests_root:
196-
# Resolve path relative to config file directory or current working directory
197+
is_valid, error_msg = validate_relative_directory_path(tests_root)
198+
if not is_valid:
199+
return {
200+
"status": "error",
201+
"message": f"Invalid 'tests_root': {error_msg}",
202+
"field_errors": {"tests_root": error_msg},
203+
}
204+
# Validate tests_root directory exists if provided
197205
base_dir = cfg_file.parent if cfg_file else Path.cwd()
198206
tests_root_path = (base_dir / tests_root).resolve()
199207
if not tests_root_path.exists() or not tests_root_path.is_dir():
@@ -203,8 +211,19 @@ def get_config_value(key: str, default: str = "") -> str:
203211
"field_errors": {"tests_root": f"Directory does not exist at {tests_root_path}"},
204212
}
205213

214+
# Validate module_root path format and safety
215+
module_root = get_config_value("module_root", "")
216+
if module_root:
217+
is_valid, error_msg = validate_relative_directory_path(module_root)
218+
if not is_valid:
219+
return {
220+
"status": "error",
221+
"message": f"Invalid 'module_root': {error_msg}",
222+
"field_errors": {"module_root": error_msg},
223+
}
224+
206225
setup_info = VsCodeSetupInfo(
207-
module_root=get_config_value("module_root", ""),
226+
module_root=module_root,
208227
tests_root=tests_root,
209228
test_framework=get_config_value("test_framework", "pytest"),
210229
formatter=get_formatter_cmds(get_config_value("formatter_cmds", "disabled")),

0 commit comments

Comments
 (0)