diff --git a/airflow-core/src/airflow/utils/email.py b/airflow-core/src/airflow/utils/email.py index 393841efa3dd3..46f24fba0f509 100644 --- a/airflow-core/src/airflow/utils/email.py +++ b/airflow-core/src/airflow/utils/email.py @@ -208,7 +208,7 @@ def build_mime_message( basename = os.path.basename(fname) with open(fname, "rb") as file: part = MIMEApplication(file.read(), Name=basename) - part["Content-Disposition"] = f'attachment; filename="{basename}"' + part.add_header("Content-Disposition", "attachment", filename=basename) part["Content-ID"] = f"<{basename}>" msg.attach(part) diff --git a/airflow-core/tests/unit/utils/test_email.py b/airflow-core/tests/unit/utils/test_email.py index f73edeb3679a3..33f242fc72f85 100644 --- a/airflow-core/tests/unit/utils/test_email.py +++ b/airflow-core/tests/unit/utils/test_email.py @@ -146,6 +146,25 @@ def test_build_mime_message(self): assert [mail_to] == recipients assert msg["To"] == ",".join(recipients) + def test_build_mime_message_escapes_attachment_filename(self, tmp_path): + # A quote in the filename must not break out of the quoted + # Content-Disposition value and inject extra parameters. + malicious = 'report.txt"; x-evil="1' + attachment = tmp_path / malicious + attachment.write_bytes(b"data") + + msg, _ = email.build_mime_message( + mail_from="from@example.com", + to="to@example.com", + subject="subject", + html_content="", + files=[os.fspath(attachment)], + ) + + part = msg.get_payload()[-1] + assert part.get_filename() == malicious + assert "x-evil" not in dict(part.get_params(header="Content-Disposition")) + @pytest.mark.db_test class TestEmailSmtp: diff --git a/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py b/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py index 991d13c1abc1b..d6b56c1198858 100644 --- a/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py +++ b/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py @@ -573,7 +573,7 @@ def _build_mime_message( basename = os.path.basename(fname) with open(fname, "rb") as file: part = MIMEApplication(file.read(), Name=basename) - part["Content-Disposition"] = f'attachment; filename="{basename}"' + part.add_header("Content-Disposition", "attachment", filename=basename) part["Content-ID"] = f"<{basename}>" msg.attach(part) diff --git a/providers/smtp/tests/unit/smtp/hooks/test_smtp.py b/providers/smtp/tests/unit/smtp/hooks/test_smtp.py index 13e494306c71b..e3a3f557f70ea 100644 --- a/providers/smtp/tests/unit/smtp/hooks/test_smtp.py +++ b/providers/smtp/tests/unit/smtp/hooks/test_smtp.py @@ -235,6 +235,26 @@ def test_build_mime_message(self, mock_smtplib): assert [mail_to] == recipients assert msg["To"] == ",".join(recipients) + @patch(smtplib_string) + def test_build_mime_message_escapes_attachment_filename(self, mock_smtplib, tmp_path): + # A quote in the filename must not break out of the quoted + # Content-Disposition value and inject extra parameters. + malicious = 'report.txt"; x-evil="1' + attachment = tmp_path / malicious + attachment.write_bytes(b"data") + with SmtpHook() as smtp_hook: + msg, _ = smtp_hook._build_mime_message( + mail_from=FROM_EMAIL, + to=TO_EMAIL, + subject=TEST_SUBJECT, + html_content=TEST_BODY, + files=[os.fspath(attachment)], + ) + + part = msg.get_payload()[-1] + assert part.get_filename() == malicious + assert "x-evil" not in dict(part.get_params(header="Content-Disposition")) + @patch(smtplib_string) def test_send_smtp(self, mock_smtplib): mock_send_mime = mock_smtplib.SMTP_SSL().sendmail