This is an automated email from the ASF dual-hosted git repository.
bugraoz 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 eae5f754463 Prevent path traversal via AIRFLOW_CLI_ENVIRONMENT in
airflowctl (#64618)
eae5f754463 is described below
commit eae5f754463dff63ef2a87d3049e871cf837362a
Author: rjgoyln <[email protected]>
AuthorDate: Mon Apr 13 00:41:38 2026 +0800
Prevent path traversal via AIRFLOW_CLI_ENVIRONMENT in airflowctl (#64618)
* replace by target.is_relative_to(base)
* Update airflow-ctl/tests/airflow_ctl/api/test_client.py
Co-authored-by: Copilot <[email protected]>
* fix(cli): add path traversal protection and improve exception handling
---------
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)