This is an automated email from the ASF dual-hosted git repository.
o-nikolas 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 1161cf9b004 prevent path traversal in s3 to sftp/ftp transfer
destinations (#68984)
1161cf9b004 is described below
commit 1161cf9b0042c018827f6d6a09f2aa9e9be3b020
Author: Samina <[email protected]>
AuthorDate: Wed Jul 1 03:48:03 2026 +0530
prevent path traversal in s3 to sftp/ftp transfer destinations (#68984)
S3ToSFTPOperator and S3ToFTPOperator build the remote destination by
concatenating S3 key suffixes returned by list_keys onto the configured
sftp_path/ftp_path, so an object whose name contains ../ segments resolves
outside that directory on the SFTP/FTP server and the upload lands wherever the
server canonicalises it. Object names are arbitrary strings controlled by
anyone who can write to the source bucket, so the resolved destination is
validated to stay within the configured path [...]
---
.../providers/amazon/aws/transfers/s3_to_ftp.py | 2 ++
.../providers/amazon/aws/transfers/s3_to_sftp.py | 2 ++
.../airflow/providers/amazon/aws/utils/__init__.py | 26 ++++++++++++++++++++++
.../unit/amazon/aws/transfers/test_s3_to_ftp.py | 20 +++++++++++++++++
.../unit/amazon/aws/transfers/test_s3_to_sftp.py | 20 +++++++++++++++++
5 files changed, 70 insertions(+)
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/transfers/s3_to_ftp.py
b/providers/amazon/src/airflow/providers/amazon/aws/transfers/s3_to_ftp.py
index ad532e9ff1d..ac8039315cd 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/transfers/s3_to_ftp.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/transfers/s3_to_ftp.py
@@ -22,6 +22,7 @@ from tempfile import NamedTemporaryFile
from typing import TYPE_CHECKING
from airflow.providers.amazon.aws.hooks.s3 import S3Hook
+from airflow.providers.amazon.aws.utils import validate_destination_path
from airflow.providers.common.compat.sdk import BaseOperator
from airflow.providers.ftp.hooks.ftp import FTPHook
@@ -85,6 +86,7 @@ class S3ToFTPOperator(BaseOperator):
self.fail_on_file_not_exist = fail_on_file_not_exist
def _download_from_s3(self, s3_hook: S3Hook, ftp_hook: FTPHook, s3_key:
str, ftp_path: str) -> None:
+ validate_destination_path(ftp_path, self.ftp_path,
base_name="ftp_path")
if not s3_hook.check_for_key(s3_key, self.s3_bucket):
if self.fail_on_file_not_exist:
raise FileNotFoundError(f"Key {s3_key!r} not found in S3
bucket {self.s3_bucket!r}")
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/transfers/s3_to_sftp.py
b/providers/amazon/src/airflow/providers/amazon/aws/transfers/s3_to_sftp.py
index b44d6fd82b9..bcb437b6910 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/transfers/s3_to_sftp.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/transfers/s3_to_sftp.py
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING
from urllib.parse import urlsplit
from airflow.providers.amazon.aws.hooks.s3 import S3Hook
+from airflow.providers.amazon.aws.utils import validate_destination_path
from airflow.providers.common.compat.sdk import BaseOperator
from airflow.providers.ssh.hooks.ssh import SSHHook
@@ -113,6 +114,7 @@ class S3ToSFTPOperator(BaseOperator):
s3_key: str,
sftp_path: str,
) -> None:
+ validate_destination_path(sftp_path, self.sftp_path,
base_name="sftp_path")
if not s3_hook.check_for_key(s3_key, self.s3_bucket):
if self.fail_on_file_not_exist:
raise FileNotFoundError(f"Key {s3_key!r} not found in S3
bucket {self.s3_bucket!r}")
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/utils/__init__.py
b/providers/amazon/src/airflow/providers/amazon/aws/utils/__init__.py
index 2c898c878c7..59d93015802 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/utils/__init__.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/utils/__init__.py
@@ -17,6 +17,7 @@
from __future__ import annotations
import logging
+import posixpath
import re
from datetime import datetime, timezone
from enum import Enum
@@ -74,6 +75,31 @@ def validate_execute_complete_event(event: dict[str, Any] |
None = None) -> dict
return event
+def validate_destination_path(destination: str, base_path: str, *, base_name:
str) -> None:
+ """
+ Ensure ``destination`` stays within ``base_path`` once resolved.
+
+ In multi-file transfers the destination is ``base_path`` concatenated with
a key suffix
+ returned by ``list_keys``. S3 object names are arbitrary strings
controlled by whoever can
+ write to the source bucket, so ``..`` segments or an absolute name could
place the upload
+ outside ``base_path`` once the remote server resolves it on its host.
``base_name`` is the
+ operator argument name used in the error message (e.g. ``sftp_path`` or
``ftp_path``).
+ """
+ base = posixpath.normpath(base_path)
+ resolved = posixpath.normpath(destination)
+ escapes = (
+ resolved == ".."
+ or resolved.startswith("../")
+ or (posixpath.isabs(resolved) and not posixpath.isabs(base))
+ or (base != "." and resolved != base and not
resolved.startswith(base.rstrip("/") + "/"))
+ )
+ if escapes:
+ raise ValueError(
+ f"Refusing to upload S3 object to {destination!r}: resolved path "
+ f"escapes configured {base_name} {base_path!r}."
+ )
+
+
class _StringCompareEnum(Enum):
"""
An Enum class which can be compared with regular `str` and subclasses.
diff --git a/providers/amazon/tests/unit/amazon/aws/transfers/test_s3_to_ftp.py
b/providers/amazon/tests/unit/amazon/aws/transfers/test_s3_to_ftp.py
index 899708c533f..ca7e084b931 100644
--- a/providers/amazon/tests/unit/amazon/aws/transfers/test_s3_to_ftp.py
+++ b/providers/amazon/tests/unit/amazon/aws/transfers/test_s3_to_ftp.py
@@ -22,6 +22,7 @@ from unittest import mock
import pytest
from airflow.providers.amazon.aws.transfers.s3_to_ftp import S3ToFTPOperator
+from airflow.providers.amazon.aws.utils import validate_destination_path
TASK_ID = "test_s3_to_ftp"
BUCKET = "test-s3-bucket"
@@ -101,3 +102,22 @@ class TestS3ToFTPOperatorInit:
with patch.object(op.log, "info") as mock_log:
op._download_from_s3(mock_s3_hook, MagicMock(), S3_KEY,
FTP_PATH)
mock_log.assert_called_once()
+
+ @pytest.mark.parametrize(
+ "object_suffix",
+ [
+ pytest.param("../../../../etc/cron.d/evil", id="dotdot-segments"),
+ pytest.param("subdir/../../../escape", id="dotdot-cancels-base"),
+ ],
+ )
+ def test_validate_destination_path_rejects_escape(self, object_suffix):
+ # In multi-file mode the destination is ``ftp_path`` plus a key suffix
+ # returned by ``list_keys``; a crafted object name must not place the
+ # upload outside the configured ``ftp_path`` on the FTP server.
+ ftp_path = "/srv/ftp/incoming/"
+ with pytest.raises(ValueError, match="escapes configured ftp_path"):
+ validate_destination_path(ftp_path + object_suffix, ftp_path,
base_name="ftp_path")
+
+ def test_validate_destination_path_allows_contained(self):
+ ftp_path = "/srv/ftp/incoming/"
+ validate_destination_path(ftp_path + "sub/dir/report.csv", ftp_path,
base_name="ftp_path")
diff --git
a/providers/amazon/tests/unit/amazon/aws/transfers/test_s3_to_sftp.py
b/providers/amazon/tests/unit/amazon/aws/transfers/test_s3_to_sftp.py
index 867c7336ae9..fb1f04a4a3a 100644
--- a/providers/amazon/tests/unit/amazon/aws/transfers/test_s3_to_sftp.py
+++ b/providers/amazon/tests/unit/amazon/aws/transfers/test_s3_to_sftp.py
@@ -24,6 +24,7 @@ from moto import mock_aws
from airflow.models import DAG
from airflow.providers.amazon.aws.hooks.s3 import S3Hook
from airflow.providers.amazon.aws.transfers.s3_to_sftp import S3ToSFTPOperator
+from airflow.providers.amazon.aws.utils import validate_destination_path
from airflow.providers.ssh.hooks.ssh import SSHHook
from airflow.providers.ssh.operators.ssh import SSHOperator
from airflow.utils.timezone import datetime
@@ -375,3 +376,22 @@ class TestS3ToSFTPOperatorInit:
with patch.object(op.log, "info") as mock_log:
op._download_from_s3(MagicMock(), mock_s3_hook, S3_KEY,
SFTP_PATH)
mock_log.assert_called_once()
+
+ @pytest.mark.parametrize(
+ "object_suffix",
+ [
+ pytest.param("../../../../etc/cron.d/evil", id="dotdot-segments"),
+ pytest.param("subdir/../../../escape", id="dotdot-cancels-base"),
+ ],
+ )
+ def test_validate_destination_path_rejects_escape(self, object_suffix):
+ # In multi-file mode the destination is ``sftp_path`` plus a key suffix
+ # returned by ``list_keys``; a crafted object name must not place the
+ # upload outside the configured ``sftp_path`` on the SFTP server.
+ sftp_path = "/srv/sftp/incoming/"
+ with pytest.raises(ValueError, match="escapes configured sftp_path"):
+ validate_destination_path(sftp_path + object_suffix, sftp_path,
base_name="sftp_path")
+
+ def test_validate_destination_path_allows_contained(self):
+ sftp_path = "/srv/sftp/incoming/"
+ validate_destination_path(sftp_path + "sub/dir/report.csv", sftp_path,
base_name="sftp_path")