Skip to content

Commit 3bd8f02

Browse files
potiukephraimbuddy
authored andcommitted
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 120efc1)
1 parent 0e513d8 commit 3bd8f02

File tree

4 files changed

+79
-8
lines changed

4 files changed

+79
-8
lines changed

airflow/config_templates/config.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1862,6 +1862,22 @@ email:
18621862
type: string
18631863
example: "Airflow <[email protected]>"
18641864
default: ~
1865+
ssl_context:
1866+
description: |
1867+
ssl context to use when using SMTP and IMAP SSL connections. By default, the context is "default"
1868+
which sets it to ``ssl.create_default_context()`` which provides the right balance between
1869+
compatibility and security, it however requires that certificates in your operating system are
1870+
updated and that SMTP/IMAP servers of yours have valid certificates that have corresponding public
1871+
keys installed on your machines. You can switch it to "none" if you want to disable checking
1872+
of the certificates, but it is not recommended as it allows MITM (man-in-the-middle) attacks
1873+
if your infrastructure is not sufficiently secured. It should only be set temporarily while you
1874+
are fixing your certificate configuration. This can be typically done by upgrading to newer
1875+
version of the operating system you run Airflow components on,by upgrading/refreshing proper
1876+
certificates in the OS or by updating certificates for your mail servers.
1877+
type: string
1878+
version_added: 2.7.0
1879+
example: "default"
1880+
default: "default"
18651881
smtp:
18661882
description: |
18671883
If you want airflow to send emails on retries, failure, and you want to use

airflow/utils/email.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import logging
2222
import os
2323
import smtplib
24+
import ssl
2425
import warnings
2526
from email.mime.application import MIMEApplication
2627
from email.mime.multipart import MIMEMultipart
@@ -312,11 +313,20 @@ def _get_smtp_connection(host: str, port: int, timeout: int, with_ssl: bool) ->
312313
:param with_ssl: Whether to use SSL encryption for the connection.
313314
:return: An SMTP connection to the specified host and port.
314315
"""
315-
return (
316-
smtplib.SMTP_SSL(host=host, port=port, timeout=timeout)
317-
if with_ssl
318-
else smtplib.SMTP(host=host, port=port, timeout=timeout)
319-
)
316+
if not with_ssl:
317+
return smtplib.SMTP(host=host, port=port, timeout=timeout)
318+
else:
319+
ssl_context_string = conf.get("email", "SSL_CONTEXT")
320+
if ssl_context_string == "default":
321+
ssl_context = ssl.create_default_context()
322+
elif ssl_context_string == "none":
323+
ssl_context = None
324+
else:
325+
raise RuntimeError(
326+
f"The email.ssl_context configuration variable must "
327+
f"be set to 'default' or 'none' and is '{ssl_context_string}."
328+
)
329+
return smtplib.SMTP_SSL(host=host, port=port, timeout=timeout, context=ssl_context)
320330

321331

322332
def _get_email_list_from_str(addresses: str) -> list[str]:

newsfragments/33070.significant.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
In case of SMTP SSL connection, the default context now uses "default" context
2+
3+
The "default" context is Python's ``default_ssl_contest`` instead of previously used "none". The
4+
``default_ssl_context`` provides a balance between security and compatibility but in some cases,
5+
when certificates are old, self-signed or misconfigured, it might not work. This can be configured
6+
by setting "ssl_context" in "email" configuration of Airflow. Setting it to "none" brings back
7+
the "none" setting that was used in Airflow 2.6 and before, but it is not recommended due to security
8+
reasons ad this setting disables validation of certificates and allows MITM attacks.

tests/utils/test_email.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,15 +240,50 @@ def test_send_mime_conn_id(self, mock_hook, mock_smtp):
240240

241241
@mock.patch("smtplib.SMTP_SSL")
242242
@mock.patch("smtplib.SMTP")
243-
def test_send_mime_ssl(self, mock_smtp, mock_smtp_ssl):
243+
def test_send_mime_ssl_none_context(self, mock_smtp, mock_smtp_ssl):
244+
mock_smtp_ssl.return_value = mock.Mock()
245+
with conf_vars({("smtp", "smtp_ssl"): "True", ("email", "ssl_context"): "none"}):
246+
email.send_mime_email("from", "to", MIMEMultipart(), dryrun=False)
247+
assert not mock_smtp.called
248+
mock_smtp_ssl.assert_called_once_with(
249+
host=conf.get("smtp", "SMTP_HOST"),
250+
port=conf.getint("smtp", "SMTP_PORT"),
251+
timeout=conf.getint("smtp", "SMTP_TIMEOUT"),
252+
context=None,
253+
)
254+
255+
@mock.patch("smtplib.SMTP_SSL")
256+
@mock.patch("smtplib.SMTP")
257+
@mock.patch("ssl.create_default_context")
258+
def test_send_mime_ssl_default_context_if_not_set(self, create_default_context, mock_smtp, mock_smtp_ssl):
244259
mock_smtp_ssl.return_value = mock.Mock()
245260
with conf_vars({("smtp", "smtp_ssl"): "True"}):
246261
email.send_mime_email("from", "to", MIMEMultipart(), dryrun=False)
247262
assert not mock_smtp.called
263+
assert create_default_context.called
248264
mock_smtp_ssl.assert_called_once_with(
249265
host=conf.get("smtp", "SMTP_HOST"),
250266
port=conf.getint("smtp", "SMTP_PORT"),
251267
timeout=conf.getint("smtp", "SMTP_TIMEOUT"),
268+
context=create_default_context.return_value,
269+
)
270+
271+
@mock.patch("smtplib.SMTP_SSL")
272+
@mock.patch("smtplib.SMTP")
273+
@mock.patch("ssl.create_default_context")
274+
def test_send_mime_ssl_default_context_with_value_set_to_default(
275+
self, create_default_context, mock_smtp, mock_smtp_ssl
276+
):
277+
mock_smtp_ssl.return_value = mock.Mock()
278+
with conf_vars({("smtp", "smtp_ssl"): "True", ("email", "ssl_context"): "default"}):
279+
email.send_mime_email("from", "to", MIMEMultipart(), dryrun=False)
280+
assert not mock_smtp.called
281+
assert create_default_context.called
282+
mock_smtp_ssl.assert_called_once_with(
283+
host=conf.get("smtp", "SMTP_HOST"),
284+
port=conf.getint("smtp", "SMTP_PORT"),
285+
timeout=conf.getint("smtp", "SMTP_TIMEOUT"),
286+
context=create_default_context.return_value,
252287
)
253288

254289
@mock.patch("smtplib.SMTP_SSL")
@@ -284,7 +319,6 @@ def test_send_mime_complete_failure(self, mock_smtp: mock.Mock, mock_smtp_ssl: m
284319
msg = MIMEMultipart()
285320
with pytest.raises(SMTPServerDisconnected):
286321
email.send_mime_email("from", "to", msg, dryrun=False)
287-
288322
mock_smtp.assert_any_call(
289323
host=conf.get("smtp", "SMTP_HOST"),
290324
port=conf.getint("smtp", "SMTP_PORT"),
@@ -299,7 +333,8 @@ def test_send_mime_complete_failure(self, mock_smtp: mock.Mock, mock_smtp_ssl: m
299333

300334
@mock.patch("smtplib.SMTP_SSL")
301335
@mock.patch("smtplib.SMTP")
302-
def test_send_mime_ssl_complete_failure(self, mock_smtp, mock_smtp_ssl):
336+
@mock.patch("ssl.create_default_context")
337+
def test_send_mime_ssl_complete_failure(self, create_default_context, mock_smtp, mock_smtp_ssl):
303338
mock_smtp_ssl.side_effect = SMTPServerDisconnected()
304339
msg = MIMEMultipart()
305340
with conf_vars({("smtp", "smtp_ssl"): "True"}):
@@ -310,7 +345,9 @@ def test_send_mime_ssl_complete_failure(self, mock_smtp, mock_smtp_ssl):
310345
host=conf.get("smtp", "SMTP_HOST"),
311346
port=conf.getint("smtp", "SMTP_PORT"),
312347
timeout=conf.getint("smtp", "SMTP_TIMEOUT"),
348+
context=create_default_context.return_value,
313349
)
350+
assert create_default_context.called
314351
assert mock_smtp_ssl.call_count == conf.getint("smtp", "SMTP_RETRY_LIMIT")
315352
assert not mock_smtp.called
316353
assert not mock_smtp_ssl.return_value.starttls.called

0 commit comments

Comments
 (0)