You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by ep...@apache.org on 2023/08/03 18:07:24 UTC
[airflow] 01/03: Allows to choose SSL context for SMTP connection (#33070)
This is an automated email from the ASF dual-hosted git repository.
ephraimanierobi pushed a commit to branch v2-7-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 3bd8f020e8b7bdeb7f618bdbdfb3557f117b29d3
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Thu Aug 3 16:27:13 2023 +0200
Allows to choose SSL context for SMTP connection (#33070)
This change add two options to choose from when SSL SMTP connection
is created:
* default - for balance between compatibility and security
* none - in case compatibility with existing infrastructure is
preferred
(cherry picked from commit 120efc186556b1e9498f90ad436c74e5f4e138e9)
---
airflow/config_templates/config.yml | 16 ++++++++++++++
airflow/utils/email.py | 20 ++++++++++++-----
newsfragments/33070.significant.rst | 8 +++++++
tests/utils/test_email.py | 43 ++++++++++++++++++++++++++++++++++---
4 files changed, 79 insertions(+), 8 deletions(-)
diff --git a/airflow/config_templates/config.yml b/airflow/config_templates/config.yml
index 6cd249a9ce..33c3ffeb1d 100644
--- a/airflow/config_templates/config.yml
+++ b/airflow/config_templates/config.yml
@@ -1862,6 +1862,22 @@ email:
type: string
example: "Airflow <ai...@example.com>"
default: ~
+ ssl_context:
+ description: |
+ ssl context to use when using SMTP and IMAP SSL connections. By default, the context is "default"
+ which sets it to ``ssl.create_default_context()`` which provides the right balance between
+ compatibility and security, it however requires that certificates in your operating system are
+ updated and that SMTP/IMAP servers of yours have valid certificates that have corresponding public
+ keys installed on your machines. You can switch it to "none" if you want to disable checking
+ of the certificates, but it is not recommended as it allows MITM (man-in-the-middle) attacks
+ if your infrastructure is not sufficiently secured. It should only be set temporarily while you
+ are fixing your certificate configuration. This can be typically done by upgrading to newer
+ version of the operating system you run Airflow components on,by upgrading/refreshing proper
+ certificates in the OS or by updating certificates for your mail servers.
+ type: string
+ version_added: 2.7.0
+ example: "default"
+ default: "default"
smtp:
description: |
If you want airflow to send emails on retries, failure, and you want to use
diff --git a/airflow/utils/email.py b/airflow/utils/email.py
index 37e028fbd9..860b805e28 100644
--- a/airflow/utils/email.py
+++ b/airflow/utils/email.py
@@ -21,6 +21,7 @@ import collections.abc
import logging
import os
import smtplib
+import ssl
import warnings
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
@@ -312,11 +313,20 @@ def _get_smtp_connection(host: str, port: int, timeout: int, with_ssl: bool) ->
:param with_ssl: Whether to use SSL encryption for the connection.
:return: An SMTP connection to the specified host and port.
"""
- return (
- smtplib.SMTP_SSL(host=host, port=port, timeout=timeout)
- if with_ssl
- else smtplib.SMTP(host=host, port=port, timeout=timeout)
- )
+ if not with_ssl:
+ return smtplib.SMTP(host=host, port=port, timeout=timeout)
+ else:
+ ssl_context_string = conf.get("email", "SSL_CONTEXT")
+ if ssl_context_string == "default":
+ ssl_context = ssl.create_default_context()
+ elif ssl_context_string == "none":
+ ssl_context = None
+ else:
+ raise RuntimeError(
+ f"The email.ssl_context configuration variable must "
+ f"be set to 'default' or 'none' and is '{ssl_context_string}."
+ )
+ return smtplib.SMTP_SSL(host=host, port=port, timeout=timeout, context=ssl_context)
def _get_email_list_from_str(addresses: str) -> list[str]:
diff --git a/newsfragments/33070.significant.rst b/newsfragments/33070.significant.rst
new file mode 100644
index 0000000000..b71ab64eec
--- /dev/null
+++ b/newsfragments/33070.significant.rst
@@ -0,0 +1,8 @@
+In case of SMTP SSL connection, the default context now uses "default" context
+
+The "default" context is Python's ``default_ssl_contest`` instead of previously used "none". The
+``default_ssl_context`` provides a balance between security and compatibility but in some cases,
+when certificates are old, self-signed or misconfigured, it might not work. This can be configured
+by setting "ssl_context" in "email" configuration of Airflow. Setting it to "none" brings back
+the "none" setting that was used in Airflow 2.6 and before, but it is not recommended due to security
+reasons ad this setting disables validation of certificates and allows MITM attacks.
diff --git a/tests/utils/test_email.py b/tests/utils/test_email.py
index bc0ae129b1..397cd3ac29 100644
--- a/tests/utils/test_email.py
+++ b/tests/utils/test_email.py
@@ -240,15 +240,50 @@ class TestEmailSmtp:
@mock.patch("smtplib.SMTP_SSL")
@mock.patch("smtplib.SMTP")
- def test_send_mime_ssl(self, mock_smtp, mock_smtp_ssl):
+ def test_send_mime_ssl_none_context(self, mock_smtp, mock_smtp_ssl):
+ mock_smtp_ssl.return_value = mock.Mock()
+ with conf_vars({("smtp", "smtp_ssl"): "True", ("email", "ssl_context"): "none"}):
+ email.send_mime_email("from", "to", MIMEMultipart(), dryrun=False)
+ assert not mock_smtp.called
+ mock_smtp_ssl.assert_called_once_with(
+ host=conf.get("smtp", "SMTP_HOST"),
+ port=conf.getint("smtp", "SMTP_PORT"),
+ timeout=conf.getint("smtp", "SMTP_TIMEOUT"),
+ context=None,
+ )
+
+ @mock.patch("smtplib.SMTP_SSL")
+ @mock.patch("smtplib.SMTP")
+ @mock.patch("ssl.create_default_context")
+ def test_send_mime_ssl_default_context_if_not_set(self, create_default_context, mock_smtp, mock_smtp_ssl):
mock_smtp_ssl.return_value = mock.Mock()
with conf_vars({("smtp", "smtp_ssl"): "True"}):
email.send_mime_email("from", "to", MIMEMultipart(), dryrun=False)
assert not mock_smtp.called
+ assert create_default_context.called
mock_smtp_ssl.assert_called_once_with(
host=conf.get("smtp", "SMTP_HOST"),
port=conf.getint("smtp", "SMTP_PORT"),
timeout=conf.getint("smtp", "SMTP_TIMEOUT"),
+ context=create_default_context.return_value,
+ )
+
+ @mock.patch("smtplib.SMTP_SSL")
+ @mock.patch("smtplib.SMTP")
+ @mock.patch("ssl.create_default_context")
+ def test_send_mime_ssl_default_context_with_value_set_to_default(
+ self, create_default_context, mock_smtp, mock_smtp_ssl
+ ):
+ mock_smtp_ssl.return_value = mock.Mock()
+ with conf_vars({("smtp", "smtp_ssl"): "True", ("email", "ssl_context"): "default"}):
+ email.send_mime_email("from", "to", MIMEMultipart(), dryrun=False)
+ assert not mock_smtp.called
+ assert create_default_context.called
+ mock_smtp_ssl.assert_called_once_with(
+ host=conf.get("smtp", "SMTP_HOST"),
+ port=conf.getint("smtp", "SMTP_PORT"),
+ timeout=conf.getint("smtp", "SMTP_TIMEOUT"),
+ context=create_default_context.return_value,
)
@mock.patch("smtplib.SMTP_SSL")
@@ -284,7 +319,6 @@ class TestEmailSmtp:
msg = MIMEMultipart()
with pytest.raises(SMTPServerDisconnected):
email.send_mime_email("from", "to", msg, dryrun=False)
-
mock_smtp.assert_any_call(
host=conf.get("smtp", "SMTP_HOST"),
port=conf.getint("smtp", "SMTP_PORT"),
@@ -299,7 +333,8 @@ class TestEmailSmtp:
@mock.patch("smtplib.SMTP_SSL")
@mock.patch("smtplib.SMTP")
- def test_send_mime_ssl_complete_failure(self, mock_smtp, mock_smtp_ssl):
+ @mock.patch("ssl.create_default_context")
+ def test_send_mime_ssl_complete_failure(self, create_default_context, mock_smtp, mock_smtp_ssl):
mock_smtp_ssl.side_effect = SMTPServerDisconnected()
msg = MIMEMultipart()
with conf_vars({("smtp", "smtp_ssl"): "True"}):
@@ -310,7 +345,9 @@ class TestEmailSmtp:
host=conf.get("smtp", "SMTP_HOST"),
port=conf.getint("smtp", "SMTP_PORT"),
timeout=conf.getint("smtp", "SMTP_TIMEOUT"),
+ context=create_default_context.return_value,
)
+ assert create_default_context.called
assert mock_smtp_ssl.call_count == conf.getint("smtp", "SMTP_RETRY_LIMIT")
assert not mock_smtp.called
assert not mock_smtp_ssl.return_value.starttls.called