From db6eea60f23c0bca5da76469aaa8bc15a1310fd9 Mon Sep 17 00:00:00 2001 From: arpita-1111 Date: Tue, 28 Jul 2026 00:30:59 +0530 Subject: [PATCH] fix(security): sandbox repo verification in Docker to prevent RCE (#211) --- backend/app/sandbox/verify.py | 28 +++++++------ backend/app/utils/exec.py | 77 +++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 13 deletions(-) diff --git a/backend/app/sandbox/verify.py b/backend/app/sandbox/verify.py index 8d27d0d..07c6a01 100644 --- a/backend/app/sandbox/verify.py +++ b/backend/app/sandbox/verify.py @@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional from ..models import VerifyResponse -from ..utils.exec import run_cmd +from ..utils.exec import run_cmd, run_cmd_sandboxed def _find_npm_command() -> Optional[str]: @@ -52,11 +52,10 @@ def verify_repo(repo_dir: Path) -> VerifyResponse: checks: Dict[str, Any] = {} if (repo_dir / "package.json").exists(): - npm_path = _find_npm_command() - if not npm_path: - checks["npm"] = { + if shutil.which("docker") is None: + checks["docker"] = { "ok": False, - "reason": "npm not found on PATH for backend process", + "reason": "docker not found on PATH; sandboxed verification requires Docker", } return VerifyResponse(ok=False, checks=checks) @@ -65,12 +64,12 @@ def verify_repo(repo_dir: Path) -> VerifyResponse: repo_dir / "npm-shrinkwrap.json" ).exists() if has_lock: - r_ci = run_cmd([npm_path, "ci"], cwd=repo_dir, timeout_s=1200) + r_ci = run_cmd_sandboxed(["npm", "ci"], cwd=repo_dir, timeout_s=1200) checks["npm_ci"] = r_ci if r_ci.get("returncode") != 0: return VerifyResponse(ok=False, checks=checks) else: - r_i = run_cmd([npm_path, "install"], cwd=repo_dir, timeout_s=1200) + r_i = run_cmd_sandboxed(["npm", "install"], cwd=repo_dir, timeout_s=1200) checks["npm_install"] = r_i if r_i.get("returncode") != 0: return VerifyResponse(ok=False, checks=checks) @@ -96,7 +95,7 @@ def verify_repo(repo_dir: Path) -> VerifyResponse: } return VerifyResponse(ok=True, checks=checks) - r = run_cmd([npm_path, *chosen], cwd=repo_dir, timeout_s=1200) + r = run_cmd_sandboxed(["npm", *chosen], cwd=repo_dir, timeout_s=1200) checks["npm_verify"] = { "selected": chosen, "available_scripts": sorted(list(scripts.keys())), @@ -116,15 +115,18 @@ def verify_repo(repo_dir: Path) -> VerifyResponse: } return VerifyResponse(ok=True, checks=checks) - r = run_cmd(["pytest", "-q"], cwd=repo_dir, timeout_s=600) - checks["pytest"] = r - ok = r.get("returncode") == 0 - return VerifyResponse(ok=ok, checks=checks) + r = run_cmd_sandboxed( + ["pytest", "-q"], cwd=repo_dir, timeout_s=600, image="python:3.11-slim" + ) + checks["pytest"] = r + ok = r.get("returncode") == 0 + return VerifyResponse(ok=ok, checks=checks) - r = run_cmd( + r = run_cmd_sandboxed( ["python", "-c", "print('verify: no tests detected')"], cwd=repo_dir, timeout_s=30, + image="python:3.11-slim" ) checks["fallback"] = r return VerifyResponse(ok=True, checks=checks) diff --git a/backend/app/utils/exec.py b/backend/app/utils/exec.py index 6432341..f7b8cc0 100644 --- a/backend/app/utils/exec.py +++ b/backend/app/utils/exec.py @@ -48,3 +48,80 @@ def run_cmd(cmd: List[str], cwd: Path, timeout_s: int = 300) -> CmdResult: stderr=f"TimeoutExpired: {e}", ok=False, ) + + +def run_cmd_sandboxed( + cmd: List[str], + cwd: Path, + timeout_s: int = 300, + image: str = "node:20-alpine", + network: bool = False, +) -> CmdResult: + """ + Run `cmd` inside an ephemeral, non-root, resource-limited Docker + container instead of directly on the host. Prevents untrusted + repository code from achieving remote code execution (issue #211). + """ + docker_cmd = [ + "docker", + "run", + "--rm", + "--user", + "1000:1000", + "--memory", + "512m", + "--cpus", + "1", + "--pids-limit", + "256", + "--security-opt", + "no-new-privileges", + "--cap-drop", + "ALL", + "-v", + f"{cwd.resolve()}:/workspace", + "-w", + "/workspace", + ] + + if not network: + docker_cmd += ["--network", "none"] + + docker_cmd += [image, *cmd] + + env = os.environ.copy() + + try: + p = subprocess.run( + docker_cmd, + capture_output=True, + text=True, + timeout=timeout_s, + env=env, + ) + return CmdResult( + cmd=cmd, + returncode=p.returncode, + stdout=p.stdout, + stderr=p.stderr, + ok=True, + sandboxed=True, + ) + except OSError as e: + return CmdResult( + cmd=cmd, + returncode=127, + stdout="", + stderr=str(e), + ok=False, + sandboxed=True, + ) + except subprocess.TimeoutExpired as e: + return CmdResult( + cmd=cmd, + returncode=124, + stdout=getattr(e, "stdout", "") or "", + stderr=f"TimeoutExpired: {e}", + ok=False, + sandboxed=True, + ) \ No newline at end of file