This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 3b547d729e0 Quote remote job paths in posix ssh command builders 
(#70091)
3b547d729e0 is described below

commit 3b547d729e00c3005aa1918469de3ff998d1a435
Author: Samina <[email protected]>
AuthorDate: Sat Aug 1 00:01:38 2026 +0530

    Quote remote job paths in posix ssh command builders (#70091)
---
 .../src/airflow/providers/ssh/utils/remote_job.py  | 30 +++++++++++---------
 .../ssh/tests/unit/ssh/utils/test_remote_job.py    | 32 +++++++++++++++++++++-
 2 files changed, 48 insertions(+), 14 deletions(-)

diff --git a/providers/ssh/src/airflow/providers/ssh/utils/remote_job.py 
b/providers/ssh/src/airflow/providers/ssh/utils/remote_job.py
index d8d7d8ff3fd..d143dda64a7 100644
--- a/providers/ssh/src/airflow/providers/ssh/utils/remote_job.py
+++ b/providers/ssh/src/airflow/providers/ssh/utils/remote_job.py
@@ -22,6 +22,7 @@ from __future__ import annotations
 import base64
 import re
 import secrets
+import shlex
 import string
 from dataclasses import dataclass
 from typing import Literal
@@ -201,12 +202,12 @@ def build_posix_wrapper_command(
     # ``setsid`` (some macOS/BSD hosts) ``$$`` is just the job's own PID and
     # cancellation degrades to the previous single-process behaviour.
     wrapper = f"""set -euo pipefail
-job_dir='{paths.job_dir}'
-log_file='{paths.log_file}'
-exit_code_file='{paths.exit_code_file}'
-exit_code_tmp='{paths.exit_code_tmp_file}'
-pid_file='{paths.pid_file}'
-status_file='{paths.status_file}'
+job_dir={shlex.quote(paths.job_dir)}
+log_file={shlex.quote(paths.log_file)}
+exit_code_file={shlex.quote(paths.exit_code_file)}
+exit_code_tmp={shlex.quote(paths.exit_code_tmp_file)}
+pid_file={shlex.quote(paths.pid_file)}
+status_file={shlex.quote(paths.status_file)}
 
 mkdir -p "$job_dir"
 : > "$log_file"
@@ -232,7 +233,7 @@ if command -v setsid >/dev/null 2>&1; then
 else
   nohup bash -c "$job_script" </dev/null >/dev/null 2>&1 &
 fi
-echo "{paths.job_id}"
+echo {shlex.quote(paths.job_id)}
 """
     return wrapper
 
@@ -316,7 +317,7 @@ def build_posix_log_tail_command(log_file: str, offset: 
int, max_bytes: int) ->
     """
     # tail -c +N is 1-indexed, so offset 0 means start at byte 1
     tail_offset = offset + 1
-    return f"tail -c +{tail_offset} '{log_file}' 2>/dev/null | head -c 
{max_bytes} || true"
+    return f"tail -c +{tail_offset} {shlex.quote(log_file)} 2>/dev/null | head 
-c {max_bytes} || true"
 
 
 def build_windows_log_tail_command(log_file: str, offset: int, max_bytes: int) 
-> str:
@@ -354,7 +355,8 @@ def build_posix_file_size_command(file_path: str) -> str:
     :param file_path: Path to the file
     :return: Shell command that outputs the file size
     """
-    return f"stat -c%s '{file_path}' 2>/dev/null || stat -f%z '{file_path}' 
2>/dev/null || echo 0"
+    quoted = shlex.quote(file_path)
+    return f"stat -c%s {quoted} 2>/dev/null || stat -f%z {quoted} 2>/dev/null 
|| echo 0"
 
 
 def build_windows_file_size_command(file_path: str) -> str:
@@ -383,7 +385,8 @@ def build_posix_completion_check_command(exit_code_file: 
str) -> str:
     :param exit_code_file: Path to the exit code file
     :return: Shell command that outputs exit code if done, empty otherwise
     """
-    return f"test -s '{exit_code_file}' && cat '{exit_code_file}' || true"
+    quoted = shlex.quote(exit_code_file)
+    return f"test -s {quoted} && cat {quoted} || true"
 
 
 def build_windows_completion_check_command(exit_code_file: str) -> str:
@@ -423,9 +426,10 @@ def build_posix_kill_command(pid_file: str) -> str:
     :param pid_file: Path to the PID file
     :return: Shell command to kill the process
     """
+    quoted = shlex.quote(pid_file)
     return (
-        f"if test -f '{pid_file}'; then "
-        f"p=\"$(cat '{pid_file}')\"; "
+        f"if test -f {quoted}; then "
+        f'p="$(cat {quoted})"; '
         f'if [ "$p" -gt 1 ] 2>/dev/null; then '
         f'kill -TERM -"$p" 2>/dev/null || kill -TERM "$p" 2>/dev/null || true; 
'
         "fi; fi"
@@ -465,7 +469,7 @@ def build_posix_cleanup_command(job_dir: str) -> str:
     :raises ValueError: If job_dir is not under the expected base directory
     """
     _validate_job_dir(job_dir, "posix")
-    return f"rm -rf '{job_dir}'"
+    return f"rm -rf {shlex.quote(job_dir)}"
 
 
 def build_windows_cleanup_command(job_dir: str) -> str:
diff --git a/providers/ssh/tests/unit/ssh/utils/test_remote_job.py 
b/providers/ssh/tests/unit/ssh/utils/test_remote_job.py
index 53c1f6f80b4..2f626c68188 100644
--- a/providers/ssh/tests/unit/ssh/utils/test_remote_job.py
+++ b/providers/ssh/tests/unit/ssh/utils/test_remote_job.py
@@ -237,7 +237,7 @@ class TestKillCommands:
     def test_posix_kill_signals_process_group_then_falls_back(self):
         """POSIX kill targets the process group first, then a single PID as 
fallback."""
         cmd = build_posix_kill_command("/tmp/pid")
-        assert "cat '/tmp/pid'" in cmd
+        assert "cat /tmp/pid" in cmd
         # Negative PID => signal the whole process group (kills the job's 
children too)
         assert 'kill -TERM -"$p"' in cmd
         # Fallback for jobs that are not group leaders (host without setsid)
@@ -453,3 +453,33 @@ class TestCleanupCommands:
         """Test Windows cleanup rejects paths outside expected base 
directory."""
         with pytest.raises(ValueError, match="Invalid job directory"):
             build_windows_cleanup_command("C:\\temp\\other_dir")
+
+
+class TestPosixPathQuoting:
+    """A shell metacharacter in remote_base_dir must stay data, never become a 
command."""
+
+    @staticmethod
+    def _builders(paths):
+        return {
+            "wrapper": lambda: build_posix_wrapper_command("true", paths),
+            "cleanup": lambda: build_posix_cleanup_command(paths.job_dir),
+            "kill": lambda: build_posix_kill_command(paths.pid_file),
+            "log_tail": lambda: build_posix_log_tail_command(paths.log_file, 
0, 64),
+            "file_size": lambda: build_posix_file_size_command(paths.log_file),
+            "completion": lambda: 
build_posix_completion_check_command(paths.exit_code_file),
+        }
+
+    @pytest.mark.parametrize(
+        "builder",
+        ["wrapper", "cleanup", "kill", "log_tail", "file_size", "completion"],
+    )
+    def test_single_quote_in_base_dir_does_not_execute(self, builder, 
tmp_path):
+        marker = tmp_path / "injected"
+        # The prefix keeps job_dir under POSIX_DEFAULT_BASE_DIR so 
_validate_job_dir passes.
+        base_dir = f"/tmp/airflow-ssh-jobs/x'; touch {marker}; :'"
+        paths = RemoteJobPaths(job_id="job_123", remote_os="posix", 
base_dir=base_dir)
+
+        cmd = self._builders(paths)[builder]()
+        subprocess.run(["sh", "-c", cmd], capture_output=True, check=False)
+
+        assert not marker.exists()

Reply via email to