diff --git a/server/routers/devserver.py b/server/routers/devserver.py index 18f91ec1..7feab550 100644 --- a/server/routers/devserver.py +++ b/server/routers/devserver.py @@ -6,8 +6,9 @@ Uses project registry for path lookups and project_config for command detection. """ -import re +import logging import sys +import shlex from pathlib import Path from fastapi import APIRouter, HTTPException @@ -26,36 +27,328 @@ get_project_config, set_dev_command, ) +from ..utils.project_helpers import get_project_path as _get_project_path +from ..utils.validation import validate_project_name -# Add root to path for registry import +# Add root to path for security module import _root = Path(__file__).parent.parent.parent if str(_root) not in sys.path: sys.path.insert(0, str(_root)) -from registry import get_project_path as registry_get_project_path +from security import extract_commands, get_effective_commands, is_command_allowed - -def _get_project_path(project_name: str) -> Path | None: - """Get project path from registry.""" - return registry_get_project_path(project_name) +logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects/{project_name}/devserver", tags=["devserver"]) +def get_project_dir(project_name: str) -> Path: + """ + Get the validated project directory for a project name. + + Args: + project_name: Name of the project + + Returns: + Path to the project directory + + Raises: + HTTPException: If project is not found or directory does not exist + """ + project_name = validate_project_name(project_name) + project_dir = _get_project_path(project_name) + + if not project_dir: + raise HTTPException( + status_code=404, + detail=f"Project '{project_name}' not found in registry" + ) + + if not project_dir.exists(): + raise HTTPException( + status_code=404, + detail=f"Project directory not found: {project_dir}" + ) + + return project_dir + +ALLOWED_RUNNERS = {"npm", "pnpm", "yarn", "uvicorn", "python", "python3"} + +def validate_custom_command_strict(cmd: str) -> None: + """ + Strict allowlist validation for dev server commands. + Prevents arbitrary command execution (no sh -c, no cmd /c, no python -c, etc.) + """ + if not isinstance(cmd, str) or not cmd.strip(): + raise ValueError("custom_command cannot be empty") + + argv = shlex.split(cmd, posix=(sys.platform != "win32")) + if not argv: + raise ValueError("custom_command could not be parsed") + + base = Path(argv[0]).name.lower() + + # Block direct shells / interpreters commonly used for command injection + if base in {"sh", "bash", "zsh", "cmd", "powershell", "pwsh"}: + raise ValueError(f"custom_command runner not allowed: {base}") + + if base not in ALLOWED_RUNNERS: + raise ValueError(f"custom_command runner not allowed: {base}") + + # Block one-liner execution + lowered = [a.lower() for a in argv] + if base in {"python", "python3"}: + if "-c" in lowered: + raise ValueError("python -c is not allowed") + # Only allow: python -m uvicorn ... + if len(argv) < 3 or argv[1:3] != ["-m", "uvicorn"]: + raise ValueError("Only 'python -m uvicorn ...' is allowed") + + if base == "uvicorn": + if len(argv) < 2 or ":" not in argv[1]: + raise ValueError("uvicorn must specify an app like module:app") + + allowed_flags = {"--host", "--port", "--reload", "--log-level", "--workers"} + i = 2 + while i < len(argv): + a = argv[i] + if a.startswith("-") and a not in allowed_flags: + raise ValueError(f"uvicorn flag not allowed: {a}") + i += 1 + + if base in {"npm", "pnpm", "yarn"}: + # Allow only dev/start scripts (no arbitrary exec) + if base == "npm": + if len(argv) < 3 or argv[1] != "run" or argv[2] not in {"dev", "start"}: + raise ValueError("npm custom_command must be 'npm run dev' or 'npm run start'") + elif base == "pnpm": + ok = (len(argv) >= 2 and argv[1] in {"dev", "start"}) or (len(argv) >= 3 and argv[1] == "run" and argv[2] in {"dev", "start"}) + if not ok: + raise ValueError("pnpm custom_command must be 'pnpm dev/start' or 'pnpm run dev/start'") + elif base == "yarn": + ok = (len(argv) >= 2 and argv[1] in {"dev", "start"}) or (len(argv) >= 3 and argv[1] == "run" and argv[2] in {"dev", "start"}) + if not ok: + raise ValueError("yarn custom_command must be 'yarn dev/start' or 'yarn run dev/start'") + +def get_project_devserver_manager(project_name: str): + """ + Get the dev server process manager for a project. + + Args: + project_name: Name of the project + + Returns: + DevServerProcessManager instance for the project + + Raises: + HTTPException: If project is not found or directory does not exist + """ + project_dir = get_project_dir(project_name) + return get_devserver_manager(project_name, project_dir) + + +def validate_dev_command(command: str, project_dir: Path) -> None: + """ + Validate a dev server command against the security allowlist. + + Extracts all commands from the shell string and checks each against + the effective allowlist (global + org + project). Raises HTTPException + if any command is blocked or not allowed. + + Args: + command: The shell command string to validate + project_dir: Project directory for loading project-level allowlists + + Raises: + HTTPException 400: If the command fails validation + """ + commands = extract_commands(command) + if not commands: + raise HTTPException( + status_code=400, + detail="Could not parse command for security validation" + ) + + allowed_commands, blocked_commands = get_effective_commands(project_dir) + + for cmd in commands: + if cmd in blocked_commands: + logger.warning("Blocked dev server command '%s' (in blocklist) for project dir %s", cmd, project_dir) + raise HTTPException( + status_code=400, + detail=f"Command '{cmd}' is blocked and cannot be used as a dev server command" + ) + if not is_command_allowed(cmd, allowed_commands): + logger.warning("Rejected dev server command '%s' (not in allowlist) for project dir %s", cmd, project_dir) + raise HTTPException( + status_code=400, + detail=f"Command '{cmd}' is not in the allowed commands list" + ) + + # ============================================================================ -# Helper Functions +# Endpoints # ============================================================================ -def validate_project_name(name: str) -> str: - """Validate and sanitize project name to prevent path traversal.""" - if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name): +@router.get("/status", response_model=DevServerStatus) +async def get_devserver_status(project_name: str) -> DevServerStatus: + """ + Get the current status of the dev server for a project. + + Returns information about whether the dev server is running, + its process ID, detected URL, and the command used to start it. + """ + manager = get_project_devserver_manager(project_name) + + # Run healthcheck to detect crashed processes + await manager.healthcheck() + + return DevServerStatus( + status=manager.status, + pid=manager.pid, + url=manager.detected_url, + command=manager._command, + started_at=manager.started_at.isoformat() if manager.started_at else None, + ) + + +@router.post("/start", response_model=DevServerActionResponse) +async def start_devserver( + project_name: str, + request: DevServerStartRequest = DevServerStartRequest(), +) -> DevServerActionResponse: + """ + Start the dev server for a project. + + If a custom command is provided in the request, it will be used. + Otherwise, the effective command from the project configuration is used. + + Args: + project_name: Name of the project + request: Optional start request with custom command + + Returns: + Response indicating success/failure and current status + """ + manager = get_project_devserver_manager(project_name) + project_dir = get_project_dir(project_name) + + # Determine which command to use + command: str | None + if request.command: raise HTTPException( status_code=400, - detail="Invalid project name" + detail="Direct command execution is disabled. Use /config to set a safe custom_command." ) - return name + else: + command = get_dev_command(project_dir) + + if not command: + raise HTTPException( + status_code=400, + detail="No dev command available. Configure a custom command or ensure project type can be detected." + ) + + # Validate command against security allowlist before execution + validate_dev_command(command, project_dir) + + # Now command is definitely str and validated + success, message = await manager.start(command) + + return DevServerActionResponse( + success=success, + status=manager.status, + message=message, + ) + + +@router.post("/stop", response_model=DevServerActionResponse) +async def stop_devserver(project_name: str) -> DevServerActionResponse: + """ + Stop the dev server for a project. + + Gracefully terminates the dev server process and all its child processes. + + Args: + project_name: Name of the project + + Returns: + Response indicating success/failure and current status + """ + manager = get_project_devserver_manager(project_name) + + success, message = await manager.stop() + + return DevServerActionResponse( + success=success, + status=manager.status, + message=message, + ) + + +@router.get("/config", response_model=DevServerConfigResponse) +async def get_devserver_config(project_name: str) -> DevServerConfigResponse: + """ + Get the dev server configuration for a project. + + Returns information about: + - detected_type: The auto-detected project type (nodejs-vite, python-django, etc.) + - detected_command: The default command for the detected type + - custom_command: Any user-configured custom command + - effective_command: The command that will actually be used (custom or detected) + + Args: + project_name: Name of the project + + Returns: + Configuration details for the project's dev server + """ + project_dir = get_project_dir(project_name) + config = get_project_config(project_dir)""" +Dev Server Router +================= + +API endpoints for dev server control (start/stop) and configuration. +Uses project registry for path lookups and project_config for command detection. +""" + +import logging +import sys +import shlex +from pathlib import Path + +from fastapi import APIRouter, HTTPException + +from ..schemas import ( + DevServerActionResponse, + DevServerConfigResponse, + DevServerConfigUpdate, + DevServerStartRequest, + DevServerStatus, +) +from ..services.dev_server_manager import get_devserver_manager +from ..services.project_config import ( + clear_dev_command, + get_dev_command, + get_project_config, + set_dev_command, +) +from ..utils.project_helpers import get_project_path as _get_project_path +from ..utils.validation import validate_project_name + +# Add root to path for security module import +_root = Path(__file__).parent.parent.parent +if str(_root) not in sys.path: + sys.path.insert(0, str(_root)) + +from security import extract_commands, get_effective_commands, is_command_allowed + +logger = logging.getLogger(__name__) + + +router = APIRouter(prefix="/api/projects/{project_name}/devserver", tags=["devserver"]) def get_project_dir(project_name: str) -> Path: @@ -88,6 +381,63 @@ def get_project_dir(project_name: str) -> Path: return project_dir +ALLOWED_RUNNERS = {"npm", "pnpm", "yarn", "uvicorn", "python", "python3"} + +def validate_custom_command_strict(cmd: str) -> None: + """ + Strict allowlist validation for dev server commands. + Prevents arbitrary command execution (no sh -c, no cmd /c, no python -c, etc.) + """ + if not isinstance(cmd, str) or not cmd.strip(): + raise ValueError("custom_command cannot be empty") + + argv = shlex.split(cmd, posix=(sys.platform != "win32")) + if not argv: + raise ValueError("custom_command could not be parsed") + + base = Path(argv[0]).name.lower() + + # Block direct shells / interpreters commonly used for command injection + if base in {"sh", "bash", "zsh", "cmd", "powershell", "pwsh"}: + raise ValueError(f"custom_command runner not allowed: {base}") + + if base not in ALLOWED_RUNNERS: + raise ValueError(f"custom_command runner not allowed: {base}") + + # Block one-liner execution + lowered = [a.lower() for a in argv] + if base in {"python", "python3"}: + if "-c" in lowered: + raise ValueError("python -c is not allowed") + # Only allow: python -m uvicorn ... + if len(argv) < 3 or argv[1:3] != ["-m", "uvicorn"]: + raise ValueError("Only 'python -m uvicorn ...' is allowed") + + if base == "uvicorn": + if len(argv) < 2 or ":" not in argv[1]: + raise ValueError("uvicorn must specify an app like module:app") + + allowed_flags = {"--host", "--port", "--reload", "--log-level", "--workers"} + i = 2 + while i < len(argv): + a = argv[i] + if a.startswith("-") and a not in allowed_flags: + raise ValueError(f"uvicorn flag not allowed: {a}") + i += 1 + + if base in {"npm", "pnpm", "yarn"}: + # Allow only dev/start scripts (no arbitrary exec) + if base == "npm": + if len(argv) < 3 or argv[1] != "run" or argv[2] not in {"dev", "start"}: + raise ValueError("npm custom_command must be 'npm run dev' or 'npm run start'") + elif base == "pnpm": + ok = (len(argv) >= 2 and argv[1] in {"dev", "start"}) or (len(argv) >= 3 and argv[1] == "run" and argv[2] in {"dev", "start"}) + if not ok: + raise ValueError("pnpm custom_command must be 'pnpm dev/start' or 'pnpm run dev/start'") + elif base == "yarn": + ok = (len(argv) >= 2 and argv[1] in {"dev", "start"}) or (len(argv) >= 3 and argv[1] == "run" and argv[2] in {"dev", "start"}) + if not ok: + raise ValueError("yarn custom_command must be 'yarn dev/start' or 'yarn run dev/start'") def get_project_devserver_manager(project_name: str): """ @@ -106,6 +456,45 @@ def get_project_devserver_manager(project_name: str): return get_devserver_manager(project_name, project_dir) +def validate_dev_command(command: str, project_dir: Path) -> None: + """ + Validate a dev server command against the security allowlist. + + Extracts all commands from the shell string and checks each against + the effective allowlist (global + org + project). Raises HTTPException + if any command is blocked or not allowed. + + Args: + command: The shell command string to validate + project_dir: Project directory for loading project-level allowlists + + Raises: + HTTPException 400: If the command fails validation + """ + commands = extract_commands(command) + if not commands: + raise HTTPException( + status_code=400, + detail="Could not parse command for security validation" + ) + + allowed_commands, blocked_commands = get_effective_commands(project_dir) + + for cmd in commands: + if cmd in blocked_commands: + logger.warning("Blocked dev server command '%s' (in blocklist) for project dir %s", cmd, project_dir) + raise HTTPException( + status_code=400, + detail=f"Command '{cmd}' is blocked and cannot be used as a dev server command" + ) + if not is_command_allowed(cmd, allowed_commands): + logger.warning("Rejected dev server command '%s' (not in allowlist) for project dir %s", cmd, project_dir) + raise HTTPException( + status_code=400, + detail=f"Command '{cmd}' is not in the allowed commands list" + ) + + # ============================================================================ # Endpoints # ============================================================================ @@ -157,7 +546,10 @@ async def start_devserver( # Determine which command to use command: str | None if request.command: - command = request.command + raise HTTPException( + status_code=400, + detail="Direct command execution is disabled. Use /config to set a safe custom_command." + ) else: command = get_dev_command(project_dir) @@ -167,7 +559,10 @@ async def start_devserver( detail="No dev command available. Configure a custom command or ensure project type can be detected." ) - # Now command is definitely str + # Validate command against security allowlist before execution + validate_dev_command(command, project_dir) + + # Now command is definitely str and validated success, message = await manager.start(command) return DevServerActionResponse( @@ -258,8 +653,75 @@ async def update_devserver_config( except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) else: + # Validate command against security allowlist before persisting + validate_dev_command(update.custom_command, project_dir) + + # Set the custom command + try: + validate_custom_command_strict(update.custom_command) + set_dev_command(project_dir, update.custom_command) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except OSError as e: + raise HTTPException( + status_code=500, + detail=f"Failed to save configuration: {e}" + ) + + # Return updated config + config = get_project_config(project_dir) + + return DevServerConfigResponse( + detected_type=config["detected_type"], + detected_command=config["detected_command"], + custom_command=config["custom_command"], + effective_command=config["effective_command"], + ) + + + return DevServerConfigResponse( + detected_type=config["detected_type"], + detected_command=config["detected_command"], + custom_command=config["custom_command"], + effective_command=config["effective_command"], + ) + + +@router.patch("/config", response_model=DevServerConfigResponse) +async def update_devserver_config( + project_name: str, + update: DevServerConfigUpdate, +) -> DevServerConfigResponse: + """ + Update the dev server configuration for a project. + + Set custom_command to a string to override the auto-detected command. + Set custom_command to null/None to clear the custom command and revert + to using the auto-detected command. + + Args: + project_name: Name of the project + update: Configuration update containing the new custom_command + + Returns: + Updated configuration details for the project's dev server + """ + project_dir = get_project_dir(project_name) + + # Update the custom command + if update.custom_command is None: + # Clear the custom command + try: + clear_dev_command(project_dir) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + else: + # Validate command against security allowlist before persisting + validate_dev_command(update.custom_command, project_dir) + # Set the custom command try: + validate_custom_command_strict(update.custom_command) set_dev_command(project_dir, update.custom_command) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) diff --git a/server/services/dev_server_manager.py b/server/services/dev_server_manager.py index 5acfbc8b..5f8dfe83 100644 --- a/server/services/dev_server_manager.py +++ b/server/services/dev_server_manager.py @@ -17,6 +17,7 @@ import subprocess import sys import threading +import shlex from datetime import datetime from pathlib import Path from typing import Awaitable, Callable, Literal, Set @@ -24,6 +25,7 @@ import psutil from registry import list_registered_projects +from security import extract_commands, get_effective_commands, is_command_allowed from server.utils.process_utils import kill_process_tree logger = logging.getLogger(__name__) @@ -114,7 +116,8 @@ def __init__( self._callbacks_lock = threading.Lock() # Lock file to prevent multiple instances (stored in project directory) - self.lock_file = self.project_dir / ".devserver.lock" + from autocoder_paths import get_devserver_lock_path + self.lock_file = get_devserver_lock_path(self.project_dir) @property def status(self) -> Literal["stopped", "running", "crashed"]: @@ -289,39 +292,47 @@ async def start(self, command: str) -> tuple[bool, str]: Start the dev server as a subprocess. Args: - command: The shell command to run (e.g., "npm run dev") + command: The command to run (e.g., "npm run dev") Returns: Tuple of (success, message) """ - if self.status == "running": + # Already running? + if self.process and self.status == "running": return False, "Dev server is already running" + # Lock check (prevents double-start) if not self._check_lock(): - return False, "Another dev server instance is already running for this project" + return False, "Dev server already running (lock file present)" - # Validate that project directory exists - if not self.project_dir.exists(): - return False, f"Project directory does not exist: {self.project_dir}" + command = (command or "").strip() + if not command: + return False, "Empty dev server command" - self._command = command - self._detected_url = None # Reset URL detection + # SECURITY: block shell operators/metacharacters (defense-in-depth) + dangerous_ops = ["&&", "||", ";", "|", "`", "$("] + if any(op in command for op in dangerous_ops): + return False, "Shell operators are not allowed in dev server command" - try: - # Determine shell based on platform - if sys.platform == "win32": - # On Windows, use cmd.exe - shell_cmd = ["cmd", "/c", command] - else: - # On Unix-like systems, use sh - shell_cmd = ["sh", "-c", command] + # Parse into argv and execute without shell + argv = shlex.split(command, posix=(sys.platform != "win32")) + if not argv: + return False, "Empty dev server command" + + base = Path(argv[0]).name.lower() + + # Defense-in-depth: reject direct shells/interpreters commonly used for injection + if base in {"sh", "bash", "zsh", "cmd", "powershell", "pwsh"}: + return False, f"Shell runner '{base}' is not allowed for dev server commands" - # Start subprocess with piped stdout/stderr - # stdin=DEVNULL prevents interactive dev servers from blocking on stdin - # On Windows, use CREATE_NO_WINDOW to prevent console window from flashing + # Windows: use .cmd shims for Node package managers + if sys.platform == "win32" and base in {"npm", "pnpm", "yarn", "npx"} and not argv[0].lower().endswith(".cmd"): + argv[0] = argv[0] + ".cmd" + + try: if sys.platform == "win32": self.process = subprocess.Popen( - shell_cmd, + argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, @@ -330,25 +341,38 @@ async def start(self, command: str) -> tuple[bool, str]: ) else: self.process = subprocess.Popen( - shell_cmd, + argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=str(self.project_dir), ) + self._command = command + self.started_at = datetime.utcnow() + self._detected_url = None + + # Create lock once we have a PID self._create_lock() - self.started_at = datetime.now() - self.status = "running" - # Start output streaming task + # Start output streaming + self.status = "running" self._output_task = asyncio.create_task(self._stream_output()) - return True, f"Dev server started with PID {self.process.pid}" + return True, "Dev server started" + + except FileNotFoundError: + self.status = "stopped" + self.process = None + self._remove_lock() + return False, f"Command not found: {argv[0]}" except Exception as e: - logger.exception("Failed to start dev server") + self.status = "stopped" + self.process = None + self._remove_lock() return False, f"Failed to start dev server: {e}" + async def stop(self) -> tuple[bool, str]: """ Stop the dev server (SIGTERM then SIGKILL if needed). @@ -487,8 +511,18 @@ def cleanup_orphaned_devserver_locks() -> int: if not project_path.exists(): continue - lock_file = project_path / ".devserver.lock" - if not lock_file.exists(): + # Check both legacy and new locations for lock files + from autocoder_paths import get_autocoder_dir + lock_locations = [ + project_path / ".devserver.lock", + get_autocoder_dir(project_path) / ".devserver.lock", + ] + lock_file = None + for candidate in lock_locations: + if candidate.exists(): + lock_file = candidate + break + if lock_file is None: continue try: