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

bugraoz pushed a commit to branch v3-2-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-2-test by this push:
     new 8e55051fc55 [v3-2-test] Prevent path traversal via 
AIRFLOW_CLI_ENVIRONMENT in airflowctl (#64618) (#65096)
8e55051fc55 is described below

commit 8e55051fc55cb669766071ce9ba1c4000286658c
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sun Apr 12 18:43:34 2026 +0200

    [v3-2-test] Prevent path traversal via AIRFLOW_CLI_ENVIRONMENT in 
airflowctl (#64618) (#65096)
    
    * replace by target.is_relative_to(base)
    
    * Update airflow-ctl/tests/airflow_ctl/api/test_client.py
    
    * fix(cli): add path traversal protection and improve exception handling
    
    ---------
    (cherry picked from commit eae5f754463dff63ef2a87d3049e871cf837362a)
    
    Co-authored-by: rjgoyln <[email protected]>
    Co-authored-by: Copilot <[email protected]>
---
 airflow-ctl/src/airflowctl/api/client.py         | 36 +++++++++++++++++++-----
 airflow-ctl/tests/airflow_ctl/api/test_client.py | 19 +++++++++++++
 2 files changed, 48 insertions(+), 7 deletions(-)

diff --git a/airflow-ctl/src/airflowctl/api/client.py 
b/airflow-ctl/src/airflowctl/api/client.py
index 51786bfcfcb..f3ca3f673f1 100644
--- a/airflow-ctl/src/airflowctl/api/client.py
+++ b/airflow-ctl/src/airflowctl/api/client.py
@@ -26,6 +26,7 @@ import os
 import sys
 from collections.abc import Callable
 from functools import wraps
+from pathlib import Path
 from typing import TYPE_CHECKING, Any, Literal, ParamSpec, TypeVar, cast
 
 import httpx
@@ -62,6 +63,7 @@ from airflowctl.api.operations import (
 )
 from airflowctl.exceptions import (
     AirflowCtlCredentialNotFoundException,
+    AirflowCtlException,
     AirflowCtlKeyringException,
     AirflowCtlNotFoundException,
 )
@@ -144,6 +146,17 @@ def _bounded_get_new_password() -> str:
     )
 
 
+def _safe_path_under_airflow_home(airflow_home: str, filename: str) -> str:
+    base = Path(airflow_home).resolve()
+    target = (base / filename).resolve()
+    if not target.is_relative_to(base):
+        raise AirflowCtlException(
+            f"Security Error: Path traversal detected in '{filename}'. "
+            f"The resolved path must stay within AIRFLOW_HOME."
+        )
+    return str(target)
+
+
 # Credentials for the API
 class Credentials:
     """Credentials for the API."""
@@ -161,8 +174,15 @@ class Credentials:
     ):
         self.api_url = api_url
         self.api_token = api_token
-        self.api_environment = os.getenv("AIRFLOW_CLI_ENVIRONMENT") or 
api_environment
         self.client_kind = client_kind
+        raw_env = os.getenv("AIRFLOW_CLI_ENVIRONMENT") or api_environment
+        if "/" in raw_env or "\\" in raw_env or ".." in raw_env:
+            raise AirflowCtlException(
+                f"Invalid environment name: '{raw_env}'. "
+                f"Environment names cannot contain path separators ('/', '\\') 
or '..'."
+            )
+
+        self.api_environment = raw_env
 
     @property
     def input_cli_config_file(self) -> str:
@@ -183,14 +203,16 @@ class Credentials:
         """
         default_config_dir = os.environ.get("AIRFLOW_HOME", 
os.path.expanduser("~/airflow"))
         os.makedirs(default_config_dir, exist_ok=True)
-        with open(os.path.join(default_config_dir, 
self.input_cli_config_file), "w") as f:
+        config_path = _safe_path_under_airflow_home(default_config_dir, 
self.input_cli_config_file)
+        with open(config_path, "w") as f:
             json.dump({"api_url": self.api_url}, f)
 
         try:
             if os.getenv("AIRFLOW_CLI_DEBUG_MODE") == "true":
-                with open(
-                    os.path.join(default_config_dir, 
f"debug_creds_{self.input_cli_config_file}"), "w"
-                ) as f:
+                debug_path = _safe_path_under_airflow_home(
+                    default_config_dir, 
f"debug_creds_{self.input_cli_config_file}"
+                )
+                with open(debug_path, "w") as f:
                     
json.dump({self.token_key_for_environment(self.api_environment): 
self.api_token}, f)
             else:
                 if skip_keyring:
@@ -226,7 +248,7 @@ class Credentials:
     def load(self) -> Credentials:
         """Load the credentials from keyring and URL from disk file."""
         default_config_dir = os.environ.get("AIRFLOW_HOME", 
os.path.expanduser("~/airflow"))
-        config_path = os.path.join(default_config_dir, 
self.input_cli_config_file)
+        config_path = _safe_path_under_airflow_home(default_config_dir, 
self.input_cli_config_file)
         try:
             with open(config_path) as f:
                 credentials = json.load(f)
@@ -234,7 +256,7 @@ class Credentials:
                 if self.api_token is not None:
                     return self
                 if os.getenv("AIRFLOW_CLI_DEBUG_MODE") == "true":
-                    debug_creds_path = os.path.join(
+                    debug_creds_path = _safe_path_under_airflow_home(
                         default_config_dir, 
f"debug_creds_{self.input_cli_config_file}"
                     )
                     try:
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_client.py 
b/airflow-ctl/tests/airflow_ctl/api/test_client.py
index f495b357d8d..f2d216fcc82 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_client.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_client.py
@@ -32,6 +32,7 @@ from airflowctl.api.client import Client, ClientKind, 
Credentials, _bounded_get_
 from airflowctl.api.operations import ServerResponseError
 from airflowctl.exceptions import (
     AirflowCtlCredentialNotFoundException,
+    AirflowCtlException,
     AirflowCtlKeyringException,
 )
 
@@ -392,3 +393,21 @@ class TestSaveKeyringPatching:
         creds = Credentials(client_kind=ClientKind.CLI, 
api_environment="TEST_DEBUG")
         with pytest.raises(AirflowCtlCredentialNotFoundException, match="Debug 
credentials file not found"):
             creds.load()
+
+
+def test_credentials_accepts_safe_env():
+    creds = Credentials(client_kind=ClientKind.CLI, 
api_environment="prod-us_1")
+    assert creds.api_environment == "prod-us_1"
+
+
[email protected]("api_environment", ["../evil", "..\\evil", "a/b", 
"a\\b"])
+def test_credentials_rejects_unsafe_env_argument(api_environment):
+    with pytest.raises(AirflowCtlException, match="environment"):
+        Credentials(client_kind=ClientKind.CLI, 
api_environment=api_environment)
+
+
[email protected]("api_environment", ["../evil", "..\\evil", "a/b", 
"a\\b"])
+def test_credentials_rejects_unsafe_env_from_environment_variable(monkeypatch, 
api_environment):
+    monkeypatch.setenv("AIRFLOW_CLI_ENVIRONMENT", api_environment)
+    with pytest.raises(AirflowCtlException, match="environment"):
+        Credentials(client_kind=ClientKind.CLI)

Reply via email to