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 927d4ad83cb Check imap attachment symlink on the resolved destination
path (#69194)
927d4ad83cb is described below
commit 927d4ad83cbc863bb4a8c108cdf9fc25cd8e8163
Author: Samina <[email protected]>
AuthorDate: Sat Aug 1 00:44:47 2026 +0530
Check imap attachment symlink on the resolved destination path (#69194)
---
.../imap/src/airflow/providers/imap/hooks/imap.py | 18 ++++++++++---
providers/imap/tests/unit/imap/hooks/test_imap.py | 31 +++++++++++++++++-----
2 files changed, 38 insertions(+), 11 deletions(-)
diff --git a/providers/imap/src/airflow/providers/imap/hooks/imap.py
b/providers/imap/src/airflow/providers/imap/hooks/imap.py
index 60b2755ebd7..40a4711d906 100644
--- a/providers/imap/src/airflow/providers/imap/hooks/imap.py
+++ b/providers/imap/src/airflow/providers/imap/hooks/imap.py
@@ -294,7 +294,7 @@ class ImapHook(BaseHook):
self, mail_attachments: list, local_output_directory: str, overwrite:
bool = True
) -> None:
for name, payload in mail_attachments:
- if self._is_symlink(name):
+ if self._is_symlink(name, local_output_directory):
self.log.error("Can not create file because it is a symlink!")
elif self._is_escaping_current_directory(name):
self.log.error("Can not create file because it is escaping the
current directory!")
@@ -313,13 +313,23 @@ class ImapHook(BaseHook):
file_path = f"{base}_{counter}{ext}"
counter += 1
- with open(file_path, "wb") as file:
+ # O_NOFOLLOW closes the check-then-open race: if a symlink is planted
at
+ # file_path after the _is_symlink check but before this write, the open
+ # fails instead of following it. The flag is absent on some platforms
+ # (e.g. Windows), where it degrades to a no-op.
+ def opener(path: str, flags: int) -> int:
+ return os.open(path, flags | getattr(os, "O_NOFOLLOW", 0))
+
+ with open(file_path, "wb", opener=opener) as file:
file.write(payload)
- def _is_symlink(self, name: str) -> bool:
+ def _is_symlink(self, name: str, local_output_directory: str) -> bool:
+ # Check the resolved destination, not the bare attachment name: an
+ # attacker-supplied name is written under local_output_directory, so a
+ # symlink planted there is what would be followed by the open() below.
# IMPORTANT NOTE: os.path.islink is not working for windows symlinks
# See: https://stackoverflow.com/a/11068434
- return os.path.islink(name)
+ return os.path.islink(self._correct_path(name, local_output_directory))
def _is_escaping_current_directory(self, name: str) -> bool:
return f"..{os.sep}" in name
diff --git a/providers/imap/tests/unit/imap/hooks/test_imap.py
b/providers/imap/tests/unit/imap/hooks/test_imap.py
index 0b8a7fa2f80..bb6d4fc7a68 100644
--- a/providers/imap/tests/unit/imap/hooks/test_imap.py
+++ b/providers/imap/tests/unit/imap/hooks/test_imap.py
@@ -19,7 +19,8 @@ from __future__ import annotations
import imaplib
import json
-from unittest.mock import Mock, mock_open, patch
+import os
+from unittest.mock import ANY, Mock, mock_open, patch
import pytest
@@ -313,7 +314,7 @@ class TestImapHook:
with ImapHook() as imap_hook:
imap_hook.download_mail_attachments("test1.csv", "test_directory")
- mock_open_method.assert_called_once_with("test_directory/test1.csv",
"wb")
+ mock_open_method.assert_called_once_with("test_directory/test1.csv",
"wb", opener=ANY)
mock_open_method.return_value.write.assert_called_once_with(b"SWQsTmFtZQoxLEZlbGl4")
@patch("airflow.providers.imap.hooks.imap.os.path.exists")
@@ -327,7 +328,7 @@ class TestImapHook:
imap_hook.download_mail_attachments("test1.csv", "test_directory",
overwrite=True)
# with overwrite=True, it should still write to the original filename
- mock_open_method.assert_called_once_with("test_directory/test1.csv",
"wb")
+ mock_open_method.assert_called_once_with("test_directory/test1.csv",
"wb", opener=ANY)
@patch("airflow.providers.imap.hooks.imap.os.path.exists")
@patch(open_string, new_callable=mock_open)
@@ -341,7 +342,7 @@ class TestImapHook:
imap_hook.download_mail_attachments("test1.csv", "test_directory",
overwrite=False)
# with overwrite=False, it should write to a renamed file
- mock_open_method.assert_called_once_with("test_directory/test1_1.csv",
"wb")
+ mock_open_method.assert_called_once_with("test_directory/test1_1.csv",
"wb", opener=ANY)
@patch(open_string, new_callable=mock_open)
@patch(imaplib_string)
@@ -365,7 +366,7 @@ class TestImapHook:
name=r"test(\d+).csv",
local_output_directory="test_directory", check_regex=True
)
- mock_open_method.assert_called_once_with("test_directory/test1.csv",
"wb")
+ mock_open_method.assert_called_once_with("test_directory/test1.csv",
"wb", opener=ANY)
mock_open_method.return_value.write.assert_called_once_with(b"SWQsTmFtZQoxLEZlbGl4")
@patch(open_string, new_callable=mock_open)
@@ -394,7 +395,7 @@ class TestImapHook:
name="test1.csv", local_output_directory="test_directory",
latest_only=True
)
- mock_open_method.assert_called_once_with("test_directory/test1.csv",
"wb")
+ mock_open_method.assert_called_once_with("test_directory/test1.csv",
"wb", opener=ANY)
mock_open_method.return_value.write.assert_called_once_with(b"SWQsTmFtZQoxLEZlbGl4")
@patch(open_string, new_callable=mock_open)
@@ -417,10 +418,26 @@ class TestImapHook:
with ImapHook() as imap_hook:
imap_hook.download_mail_attachments(name="symlink",
local_output_directory="test_directory")
- assert mock_is_symlink.call_count == 1
+ mock_is_symlink.assert_called_once_with("test_directory/symlink")
mock_open_method.assert_not_called()
mock_open_method.return_value.write.assert_not_called()
+ @patch("airflow.providers.imap.hooks.imap.os.open")
+ @patch(open_string, new_callable=mock_open)
+ @patch(imaplib_string)
+ def test_download_mail_attachments_opens_without_following_symlink(
+ self, mock_imaplib, mock_open_method, mock_os_open
+ ):
+ _create_fake_imap(mock_imaplib, with_mail=True)
+
+ with ImapHook() as imap_hook:
+ imap_hook.download_mail_attachments("test1.csv", "test_directory")
+
+ opener = mock_open_method.call_args.kwargs["opener"]
+ opener("test_directory/test1.csv", os.O_WRONLY | os.O_CREAT |
os.O_TRUNC)
+ flags = mock_os_open.call_args.args[1]
+ assert flags & getattr(os, "O_NOFOLLOW", 0) == getattr(os,
"O_NOFOLLOW", 0)
+
@patch(open_string, new_callable=mock_open)
@patch(imaplib_string)
def test_download_mail_attachments_with_mail_filter(self, mock_imaplib,
mock_open_method):