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 e1d881b5c4f Fix `SSHRemoteJobOperator` mangling environment values 
with spaces or shell characters (#74810)
e1d881b5c4f is described below

commit e1d881b5c4f104d64817f6d7f19fe6e6023a81fd
Author: Kaxil Naik <[email protected]>
AuthorDate: Fri Oct 16 00:13:50 2026 +0100

    Fix `SSHRemoteJobOperator` mangling environment values with spaces or shell 
characters (#74810)
    
    * Fix SSHRemoteJobOperator mangling environment values with spaces or shell 
characters
    
    On POSIX hosts each environment value was escaped for one level of single
    quotes, but the export lines are spliced into the single-quoted job_script
    literal, so the value's opening quote closed that literal. A value with a
    space broke the wrapper, and $(...), backticks or ; in a value were run by
    the wrapper shell instead of reaching the job.
    
    Quote each value with shlex.quote, then escape the export block for the
    job_script literal the same way the command already is.
    
    * Document that SSHRemoteJobOperator passes environment values literally
    
    Values used to be expanded by the remote shell as a side effect of the 
quoting bug, so $VAR in a value no longer works. Say so in the docstring and 
the operator docs, escape the exports and command in one step, and cover 
backslashes and braced variables in the test.
---
 providers/ssh/docs/operators/ssh_remote_job.rst    |  4 +++
 .../providers/ssh/operators/ssh_remote_job.py      |  4 ++-
 .../src/airflow/providers/ssh/utils/remote_job.py  |  9 ++---
 .../ssh/tests/unit/ssh/utils/test_remote_job.py    | 42 ++++++++++++++++++++--
 4 files changed, 52 insertions(+), 7 deletions(-)

diff --git a/providers/ssh/docs/operators/ssh_remote_job.rst 
b/providers/ssh/docs/operators/ssh_remote_job.rst
index 7a8783b9861..f675bf6288e 100644
--- a/providers/ssh/docs/operators/ssh_remote_job.rst
+++ b/providers/ssh/docs/operators/ssh_remote_job.rst
@@ -114,6 +114,10 @@ With Environment Variables
         },
     )
 
+The job receives each value as a literal string: the remote shell does not 
expand ``$VAR``,
+``~`` or ``$(...)`` inside it. To build a value from the remote environment, 
such as extending
+``PATH``, do it in ``command``, for example 
``command="PATH=$PATH:/opt/tools/bin python process_data.py"``.
+
 Windows Remote Host
 ^^^^^^^^^^^^^^^^^^^
 
diff --git 
a/providers/ssh/src/airflow/providers/ssh/operators/ssh_remote_job.py 
b/providers/ssh/src/airflow/providers/ssh/operators/ssh_remote_job.py
index f0d9e5160eb..eab60940600 100644
--- a/providers/ssh/src/airflow/providers/ssh/operators/ssh_remote_job.py
+++ b/providers/ssh/src/airflow/providers/ssh/operators/ssh_remote_job.py
@@ -63,7 +63,9 @@ class SSHRemoteJobOperator(BaseOperator):
     :param ssh_conn_id: SSH connection ID from Airflow Connections
     :param command: Command to execute on the remote host (templated)
     :param remote_host: Override the host from the connection (templated)
-    :param environment: Environment variables to set for the command 
(templated)
+    :param environment: Environment variables to set for the command 
(templated).
+        Values are passed as literal strings; the remote shell does not expand 
``$VAR``
+        or ``$(...)`` in them.
     :param remote_base_dir: Base directory for job artifacts (templated).
         Defaults to /tmp/airflow-ssh-jobs on POSIX, 
C:\\Windows\\Temp\\airflow-ssh-jobs on Windows
     :param poll_interval: Seconds between status polls (default: 5)
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 7cbfd5a529f..557bfef679e 100644
--- a/providers/ssh/src/airflow/providers/ssh/utils/remote_job.py
+++ b/providers/ssh/src/airflow/providers/ssh/utils/remote_job.py
@@ -183,10 +183,11 @@ def build_posix_wrapper_command(
     if environment:
         for key, value in environment.items():
             _validate_env_var_name(key)
-            escaped_value = value.replace("'", "'\"'\"'")
-            env_exports += f"export {key}='{escaped_value}'\n"
+            env_exports += f"export {key}={shlex.quote(value)}\n"
 
-    escaped_command = command.replace("'", "'\"'\"'")
+    # The exports and the command are spliced into the single-quoted 
``job_script`` literal
+    # below, so escape them for it once more on top of the per-value quoting.
+    escaped_job_body = (env_exports + command).replace("'", "'\"'\"'")
 
     # Launch detached under ``setsid`` so the job is its own 
session/process-group
     # leader, letting cancellation signal the whole job tree instead of 
orphaning the
@@ -221,7 +222,7 @@ set +e
 echo -n "$$" > "'"$pid_file"'"
 export LOG_FILE="'"$log_file"'"
 export STATUS_FILE="'"$status_file"'"
-{env_exports}{escaped_command} >>"'"$log_file"'" 2>&1
+{escaped_job_body} >>"'"$log_file"'" 2>&1
 ec=$?
 echo -n "$ec" > "'"$exit_code_tmp"'"
 mv "'"$exit_code_tmp"'" "'"$exit_code_file"'"
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 2960de56584..e743c9fdd93 100644
--- a/providers/ssh/tests/unit/ssh/utils/test_remote_job.py
+++ b/providers/ssh/tests/unit/ssh/utils/test_remote_job.py
@@ -126,8 +126,8 @@ class TestBuildPosixWrapperCommand:
             environment={"MY_VAR": "my_value", "OTHER": "test"},
         )
 
-        assert "export MY_VAR='my_value'" in wrapper
-        assert "export OTHER='test'" in wrapper
+        assert "export MY_VAR=my_value" in wrapper
+        assert "export OTHER=test" in wrapper
 
     def test_escapes_quotes(self):
         """Test that single quotes in command are escaped."""
@@ -504,3 +504,41 @@ class TestPosixPathQuoting:
         subprocess.run(["sh", "-c", cmd], capture_output=True, check=False)
 
         assert not marker.exists()
+
+
[email protected](shutil.which("bash") is None, reason="needs bash to run 
the wrapper")
+class TestPosixEnvironmentQuoting:
+    """An environment value must reach the job verbatim and never run as shell 
code."""
+
+    @pytest.mark.parametrize(
+        "value",
+        [
+            pytest.param("two words", id="space"),
+            pytest.param("x$(touch {marker})", id="command-substitution"),
+            pytest.param("x`touch {marker}`", id="backticks"),
+            pytest.param("x; touch {marker}", id="semicolon"),
+            pytest.param("it's'; touch {marker}; '", id="single-quotes"),
+            pytest.param('say "hi" to $HOME', id="double-quotes-and-variable"),
+            pytest.param("${PATH}:~/bin", id="braced-variable-and-tilde"),
+            pytest.param("C:\\data\\\\share", id="backslashes"),
+            pytest.param("line one\nline two", id="newline"),
+            pytest.param("", id="empty"),
+        ],
+    )
+    def test_value_reaches_job_verbatim(self, value, tmp_path):
+        marker = tmp_path / "injected"
+        value = value.replace("{marker}", str(marker))
+        paths = RemoteJobPaths(job_id="env_job", remote_os="posix", 
base_dir=str(tmp_path / "jobs"))
+        wrapper = build_posix_wrapper_command('printf %s "$MY_VAR"', paths, 
environment={"MY_VAR": value})
+
+        subprocess.run(["bash", "-c", wrapper], check=True, 
capture_output=True)
+        # The job runs detached, so wait for it to record its exit code.
+        exit_code_file = Path(paths.exit_code_file)
+        deadline = time.monotonic() + 5
+        while time.monotonic() < deadline and not exit_code_file.exists():
+            time.sleep(0.02)
+
+        assert not marker.exists()
+        assert exit_code_file.exists(), "job never recorded its exit code"
+        assert exit_code_file.read_text() == "0"
+        assert Path(paths.log_file).read_text() == value

Reply via email to