Repository navigation
fix(server): harden subprocess execution against command and PATH injection (#44) #112
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -60,14 +60,42 @@ def check_display_env() -> None: | |
| os.environ["DISPLAY"] = ":0" | ||
|
|
||
|
|
||
| # Répertoires système de confiance pour la résolution des binaires externes | ||
| # (mitige le détournement de PATH : shutil.which n'est jamais appelé avec le PATH ambiant) | ||
| _TRUSTED_BIN_DIRS = ["/usr/local/bin", "/usr/bin", "/bin", "/usr/local/sbin", "/usr/sbin", "/sbin"] | ||
| if os.name == "nt": | ||
| _win_root = os.environ.get("SystemRoot", r"C:\Windows") | ||
| _TRUSTED_BIN_DIRS = [os.path.join(_win_root, "System32"), _win_root] | ||
|
|
||
| # Métacaractères de shell interdits dans les arguments transmis aux sous-processus | ||
| _SHELL_UNSAFE_CHARS = frozenset(";|&$`<>\n\r") | ||
|
|
||
|
|
||
| def _find_trusted_bin(name: str, fallback: str | None = None) -> str | None: | ||
| """Résout un binaire externe uniquement dans des répertoires système de confiance (chemin absolu).""" | ||
| resolved = shutil.which(name, path=os.pathsep.join(_TRUSTED_BIN_DIRS)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
ArtifactsFocused Python reproduction using a one-argument shutil.which double
Captured source of the focused trusted-bin reproduction
Before run succeeds with one-argument shutil.which double under Xvfb
Changed _find_trusted_bin run raises TypeError under Xvfb
Prompt To Fix With AIThis is a comment left during a code review.
Path: gui_agent/server.py
Line: 76
Comment:
**Trusted lookup breaks existing `which` doubles**
`_find_trusted_bin` now calls `shutil.which` with `path=`, but the subprocess-related tests replace it with one-argument callables. Calling this helper therefore raises `TypeError` before those tests reach their assertions, including the mocks in `tests/test_package.py`. Update those test doubles to accept the `path` argument while retaining the explicit trusted-path lookup.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
printf '%s\n' '--- repository conventions ---'
find /tmp/coderabbit-repo-knowledge/leandre755-gui-agent-5b034a55 -maxdepth 2 -type f -name '*.md' -print
printf '%s\n' '--- target function and direct callers ---'
sed -n '60,110p' gui_agent/server.py
sed -n '775,805p' gui_agent/server.py
rg -n -C 3 '_find_trusted_bin|shutil\.which|gui_app_launch' gui_agent/server.py
printf '%s\n' '--- bound shutil.which implementation and contract-relevant branch ---'
python3 - <<'PY'
import inspect, shutil
print(inspect.getsourcefile(shutil.which))
print(inspect.getsource(shutil.which))
PYRepository: leandre755/gui_agent Length of output: 11692 🏁 Script executed: #!/bin/bash
sed -n '1100,1185p' gui_agent/server.py
rg -n -C 4 '_resolve_user_executable|subprocess\.(run|Popen)|shlex\.split' gui_agent/server.pyRepository: leandre755/gui_agent Length of output: 10141 Bloquez les noms contenant un composant de chemin.
🤖 Prompt for AI Agents |
||
| if resolved: | ||
| return resolved | ||
| if fallback and os.path.isfile(fallback) and os.access(fallback, os.X_OK): | ||
| return fallback | ||
| return None | ||
|
|
||
|
|
||
| def _resolve_user_executable(name: str) -> str | None: | ||
| """Résout l'exécutable demandé : chemin absolu existant et exécutable, ou binaire des répertoires de confiance.""" | ||
| if os.path.isabs(name): | ||
| return name if os.path.isfile(name) and os.access(name, os.X_OK) else None | ||
| return _find_trusted_bin(name) | ||
|
|
||
|
|
||
| def capture_screen_pil(monitor_index: int = 1) -> Image.Image: | ||
| """ | ||
| Capture l'écran actif avec fallback automatique sur spectacle (Wayland/X11) | ||
| si mss retourne une image vide ou uniforme. | ||
| """ | ||
| check_display_env() | ||
| # 1. Tentative via spectacle (indispensable sous XWayland/KDE si mss capture un framebuffer noir) | ||
| spectacle_bin = shutil.which("spectacle") | ||
| spectacle_bin = _find_trusted_bin("spectacle") | ||
| if spectacle_bin: | ||
| with tempfile.NamedTemporaryFile(suffix=".png", prefix="_mcp_screen_tmp_", delete=False) as tmp_file: | ||
| tmp_path = tmp_file.name | ||
|
|
@@ -763,7 +791,7 @@ def run_xdotool(args: list) -> bool: | |
| try: | ||
| env = os.environ.copy() | ||
| env["DISPLAY"] = env.get("DISPLAY", ":0") | ||
| xdotool_bin = shutil.which("xdotool") or "/bin/xdotool" | ||
| xdotool_bin = _find_trusted_bin("xdotool", "/bin/xdotool") | ||
| res = subprocess.run([xdotool_bin, *args], env=env, capture_output=True, check=False, timeout=5) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| stderr_txt = res.stderr.decode("utf-8", errors="replace").strip() | ||
| if res.returncode != 0 or "No such key name" in stderr_txt or "Ignoring it" in stderr_txt: | ||
|
|
@@ -883,9 +911,9 @@ def gui_window_list() -> dict[str, Any]: | |
| check_display_env() | ||
|
|
||
| windows = [] | ||
| wmctrl_bin = shutil.which("wmctrl") | ||
| xdotool_bin = shutil.which("xdotool") or "/bin/xdotool" | ||
| xprop_bin = shutil.which("xprop") or "/usr/bin/xprop" | ||
| wmctrl_bin = _find_trusted_bin("wmctrl") | ||
| xdotool_bin = _find_trusted_bin("xdotool", "/bin/xdotool") | ||
| xprop_bin = _find_trusted_bin("xprop", "/usr/bin/xprop") | ||
|
|
||
| try: | ||
| deadline = time.monotonic() + 5.0 | ||
|
|
@@ -976,7 +1004,7 @@ def gui_window_focus(window_id: int) -> dict[str, Any]: | |
| if not isinstance(window_id, int) or window_id <= 0: | ||
| return {"status": "error", "message": "window_id doit être un entier positif."} | ||
| check_display_env() | ||
| xdotool_bin = shutil.which("xdotool") or "/bin/xdotool" | ||
| xdotool_bin = _find_trusted_bin("xdotool", "/bin/xdotool") | ||
|
|
||
| try: | ||
| subprocess.run([xdotool_bin, "windowactivate", str(window_id)], check=True, stderr=subprocess.PIPE, timeout=5) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
@@ -1008,7 +1036,7 @@ def gui_window_resize_move(window_id: int, x: int, y: int, width: int, height: i | |
| return {"status": "error", "message": "width et height doivent être strictly positifs."} | ||
|
|
||
| check_display_env() | ||
| xdotool_bin = shutil.which("xdotool") or "/bin/xdotool" | ||
| xdotool_bin = _find_trusted_bin("xdotool", "/bin/xdotool") | ||
|
|
||
| try: | ||
| subprocess.run( | ||
|
|
@@ -1059,7 +1087,7 @@ def gui_window_close(window_id: int) -> dict[str, Any]: | |
| if not isinstance(window_id, int) or window_id <= 0: | ||
| return {"status": "error", "message": "window_id doit être un entier positif."} | ||
| check_display_env() | ||
| xdotool_bin = shutil.which("xdotool") or "/bin/xdotool" | ||
| xdotool_bin = _find_trusted_bin("xdotool", "/bin/xdotool") | ||
|
|
||
| try: | ||
| subprocess.run([xdotool_bin, "windowclose", str(window_id)], check=True, stderr=subprocess.PIPE, timeout=5) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
@@ -1089,6 +1117,20 @@ def gui_app_launch(command: str, background: bool = True) -> dict[str, Any]: | |
| if not cmd_args: | ||
| return {"status": "error", "message": "Commande vide ou invalide."} | ||
|
|
||
| # Zero-trust : refuser tout métacaractère de shell dans les arguments transmis au processus (issue #44) | ||
| unsafe_arg = next((arg for arg in cmd_args if not _SHELL_UNSAFE_CHARS.isdisjoint(arg)), None) | ||
| if unsafe_arg is not None: | ||
|
Comment on lines
+1121
to
+1122
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- applicable repository knowledge ---'
find /tmp/coderabbit-repo-knowledge/leandre755-gui-agent-5b034a55 -maxdepth 2 -type f -name '*.md' -print
printf '%s\n' '--- server.py definitions and launch path ---'
sed -n '1,110p' gui_agent/server.py
sed -n '1085,1155p' gui_agent/server.py
printf '%s\n' '--- relevant symbol references ---'
rg -n -C 3 '_resolve_user_executable|_SHELL_UNSAFE_CHARS|shlex\.split|subprocess\.Popen|gui_app_launch' gui_agent/server.pyRepository: leandre755/gui_agent Length of output: 11400 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- repository conventions ---'
for f in /tmp/coderabbit-repo-knowledge/leandre755-gui-agent-5b034a55/conventions/*.md; do
printf '\n### %s\n' "$f"
head -80 "$f"
done
printf '%s\n' '--- deterministic parsing and blacklist check ---'
python3 - <<'PY'
import shlex
unsafe = frozenset(";|&$`<>\n\r")
command = "/bin/sh -c 'touch /tmp/pwned'"
args = shlex.split(command)
print("args:", args)
print("unsafe_args:", [arg for arg in args if not unsafe.isdisjoint(arg)])
PYRepository: leandre755/gui_agent Length of output: 5983 Remplacez la liste noire par une politique d’exécution autorisée.
Utilisez une liste blanche d’exécutables et d’arguments. N’autorisez pas les interpréteurs génériques. 🤖 Prompt for AI Agents |
||
| return { | ||
| "status": "error", | ||
| "message": f"Argument refusé (métacaractère de shell interdit) : {unsafe_arg!r}", | ||
| } | ||
|
|
||
| # Résolution en chemin absolu dans les répertoires de confiance (anti-détournement de PATH — issue #44) | ||
| executable = _resolve_user_executable(cmd_args[0]) | ||
| if executable is None: | ||
| return {"status": "error", "message": f"Exécutable introuvable ou non autorisé : '{cmd_args[0]}'"} | ||
| cmd_args[0] = executable | ||
|
|
||
| try: | ||
| if background: | ||
| proc = subprocess.Popen( | ||
|
|
@@ -1196,7 +1238,7 @@ def gui_clipboard_set(text: str) -> dict[str, Any]: | |
| logger.warning(f"Échec pyperclip.copy: {e_clip}. Tentative via xclip...") | ||
|
|
||
| # Fallback via xclip | ||
| xclip_bin = shutil.which("xclip") | ||
| xclip_bin = _find_trusted_bin("xclip") | ||
| if xclip_bin: | ||
| try: | ||
| env = os.environ.copy() | ||
|
|
@@ -1214,7 +1256,7 @@ def gui_clipboard_set(text: str) -> dict[str, Any]: | |
| logger.warning(f"Échec xclip: {e_xclip}") | ||
|
|
||
| # Fallback via xsel | ||
| xsel_bin = shutil.which("xsel") | ||
| xsel_bin = _find_trusted_bin("xsel") | ||
| if xsel_bin: | ||
| try: | ||
| env = os.environ.copy() | ||
|
|
@@ -1256,7 +1298,7 @@ def gui_clipboard_get() -> dict[str, Any]: | |
| logger.warning(f"Échec pyperclip.paste: {e_clip}. Tentative via xclip...") | ||
|
|
||
| # Fallback via xclip | ||
| xclip_bin = shutil.which("xclip") | ||
| xclip_bin = _find_trusted_bin("xclip") | ||
| if xclip_bin: | ||
| try: | ||
| env = os.environ.copy() | ||
|
|
@@ -1269,7 +1311,7 @@ def gui_clipboard_get() -> dict[str, Any]: | |
| logger.warning(f"Échec xclip -o: {e_xclip}") | ||
|
|
||
| # Fallback via xsel | ||
| xsel_bin = shutil.which("xsel") | ||
| xsel_bin = _find_trusted_bin("xsel") | ||
| if xsel_bin: | ||
| try: | ||
| env = os.environ.copy() | ||
|
|
@@ -1693,7 +1735,7 @@ def gui_start_video_recording( | |
|
|
||
| check_display_env() | ||
|
|
||
| ffmpeg_bin = shutil.which("ffmpeg") | ||
| ffmpeg_bin = _find_trusted_bin("ffmpeg") | ||
| if not ffmpeg_bin: | ||
| return {"status": "error", "message": "Le binaire 'ffmpeg' n'est pas installé sur le système."} | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: leandre755/gui_agent
Length of output: 13305
🏁 Script executed:
Repository: leandre755/gui_agent
Length of output: 3808
Validez
SystemRootavant de construire_TRUSTED_BIN_DIRS.Avec
SystemRoot=.,shutil.which(..., path=...)recherche dans le répertoire courant. Sous Windows,shutil.whichajoute aussi ce répertoire à la recherche. Utilisez un chemin système absolu et protégé, puis ajoutez un test avecSystemRoot=..🧰 Tools
🪛 GitHub Actions: CI / 0_Build, test and static checks.txt
[error] 67-67: Ruff SIM112: Use the capitalized environment variable
SYSTEMROOTinstead ofSystemRoot.🤖 Prompt for AI Agents