Skip to content

Commit dbacacb

Browse files
potiukephraimbuddy
andcommitted
Allows to choose SSL context for SMTP provider (#33075)
* Allows to choose SSL context for SMTP provider 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 The fallback is: * The Airflow "email", "ssl_context" * "default" * Update airflow/providers/smtp/CHANGELOG.rst Co-authored-by: Ephraim Anierobi <[email protected]> (cherry picked from commit e20325d)
1 parent 2b312cd commit dbacacb

File tree

7 files changed

+171
-8
lines changed

7 files changed

+171
-8
lines changed

airflow/providers/smtp/CHANGELOG.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@
2727
Changelog
2828
---------
2929

30+
In case of SMTP SSL connection, the default context now uses "default" context
31+
32+
The "default" context is Python's ``default_ssl_context`` instead of previously used "none". The
33+
``default_ssl_context`` provides a balance between security and compatibility but in some cases,
34+
when certificates are old, self-signed or misconfigured, it might not work. This can be configured
35+
by setting "ssl_context" in "smtp_provider" configuration of the provider. If it is not explicitly set,
36+
it will default to "email", "ssl_context" setting in Airflow.
37+
38+
Setting it to "none" brings back the "none" setting that was used in previous versions of the provider,
39+
but it is not recommended due to security reasons ad this setting disables validation
40+
of certificates and allows MITM attacks.
41+
3042
1.2.0
3143
.....
3244

airflow/providers/smtp/hooks/smtp.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import os
2727
import re
2828
import smtplib
29+
import ssl
2930
from email.mime.application import MIMEApplication
3031
from email.mime.multipart import MIMEMultipart
3132
from email.mime.text import MIMEText
@@ -87,7 +88,6 @@ def get_conn(self) -> SmtpHook:
8788
if attempt < self.smtp_retry_limit:
8889
continue
8990
raise AirflowException("Unable to connect to smtp server")
90-
9191
if self.smtp_starttls:
9292
self.smtp_client.starttls()
9393
if self.smtp_user and self.smtp_password:
@@ -109,6 +109,24 @@ def _build_client(self) -> smtplib.SMTP_SSL | smtplib.SMTP:
109109
smtp_kwargs["port"] = self.port
110110
smtp_kwargs["timeout"] = self.timeout
111111

112+
if self.use_ssl:
113+
from airflow.configuration import conf
114+
115+
ssl_context_string = conf.get("smtp_provider", "SSL_CONTEXT", fallback=None)
116+
if ssl_context_string is None:
117+
ssl_context_string = conf.get("email", "SSL_CONTEXT", fallback=None)
118+
if ssl_context_string is None:
119+
ssl_context_string = "default"
120+
if ssl_context_string == "default":
121+
ssl_context = ssl.create_default_context()
122+
elif ssl_context_string == "none":
123+
ssl_context = None
124+
else:
125+
raise RuntimeError(
126+
f"The email.ssl_context configuration variable must "
127+
f"be set to 'default' or 'none' and is '{ssl_context_string}'."
128+
)
129+
smtp_kwargs["context"] = ssl_context
112130
return SMTP(**smtp_kwargs)
113131

114132
@classmethod

airflow/providers/smtp/provider.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,27 @@ connection-types:
5454

5555
notifications:
5656
- airflow.providers.smtp.notifications.smtp.SmtpNotifier
57+
58+
config:
59+
smtp_provider:
60+
description: "Options for SMTP provider."
61+
options:
62+
ssl_context:
63+
description: |
64+
ssl context to use when using SMTP and IMAP SSL connections. By default, the context is "default"
65+
which sets it to ``ssl.create_default_context()`` which provides the right balance between
66+
compatibility and security, it however requires that certificates in your operating system are
67+
updated and that SMTP/IMAP servers of yours have valid certificates that have corresponding public
68+
keys installed on your machines. You can switch it to "none" if you want to disable checking
69+
of the certificates, but it is not recommended as it allows MITM (man-in-the-middle) attacks
70+
if your infrastructure is not sufficiently secured. It should only be set temporarily while you
71+
are fixing your certificate configuration. This can be typically done by upgrading to newer
72+
version of the operating system you run Airflow components on,by upgrading/refreshing proper
73+
certificates in the OS or by updating certificates for your mail servers.
74+
75+
If you do not set this option explicitly, it will use Airflow "email.ssl_context" configuration,
76+
but if this configuration is not present, it will use "default" value.
77+
type: string
78+
version_added: 1.3.0
79+
example: "default"
80+
default: ~
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
.. Licensed to the Apache Software Foundation (ASF) under one
2+
or more contributor license agreements. See the NOTICE file
3+
distributed with this work for additional information
4+
regarding copyright ownership. The ASF licenses this file
5+
to you under the Apache License, Version 2.0 (the
6+
"License"); you may not use this file except in compliance
7+
with the License. You may obtain a copy of the License at
8+
9+
.. http://www.apache.org/licenses/LICENSE-2.0
10+
11+
.. Unless required by applicable law or agreed to in writing,
12+
software distributed under the License is distributed on an
13+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
KIND, either express or implied. See the License for the
15+
specific language governing permissions and limitations
16+
under the License.
17+
18+
.. include:: ../exts/includes/providers-configurations-ref.rst

docs/apache-airflow-providers-smtp/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
:maxdepth: 1
3535
:caption: References
3636

37+
Configuration <configurations-ref>
3738
Connection types <connections/smtp>
3839
SMTP Notifications <notifications/smtp_notifier_howto_guide>
3940
Python API <_api/airflow/providers/smtp/index>

docs/apache-airflow/configurations-ref.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ in the provider's documentation. The pre-installed providers that you may want t
3838
* :doc:`Configuration Reference for Celery Provider <apache-airflow-providers-celery:configurations-ref>`
3939
* :doc:`Configuration Reference for Apache Hive Provider <apache-airflow-providers-apache-hive:configurations-ref>`
4040
* :doc:`Configuration Reference for CNCF Kubernetes Provider <apache-airflow-providers-cncf-kubernetes:configurations-ref>`
41+
* :doc:`Configuration Reference for SMTP Provider <apache-airflow-providers-smtp:configurations-ref>`
4142

4243
.. note::
4344
For more information see :doc:`/howto/set-config`.

tests/providers/smtp/hooks/test_smtp.py

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from airflow.providers.smtp.hooks.smtp import SmtpHook
3131
from airflow.utils import db
3232
from airflow.utils.session import create_session
33+
from tests.test_utils.config import conf_vars
3334

3435
smtplib_string = "airflow.providers.smtp.hooks.smtp.smtplib"
3536

@@ -75,13 +76,16 @@ def setup_method(self):
7576
)
7677

7778
@patch(smtplib_string)
78-
def test_connect_and_disconnect(self, mock_smtplib):
79+
@patch("ssl.create_default_context")
80+
def test_connect_and_disconnect(self, create_default_context, mock_smtplib):
7981
mock_conn = _create_fake_smtp(mock_smtplib)
8082

8183
with SmtpHook():
8284
pass
83-
84-
mock_smtplib.SMTP_SSL.assert_called_once_with(host="smtp_server_address", port=465, timeout=30)
85+
assert create_default_context.called
86+
mock_smtplib.SMTP_SSL.assert_called_once_with(
87+
host="smtp_server_address", port=465, timeout=30, context=create_default_context.return_value
88+
)
8589
mock_conn.login.assert_called_once_with("smtp_user", "smtp_password")
8690
assert mock_conn.close.call_count == 1
8791

@@ -201,12 +205,90 @@ def test_hook_conn(self, mock_smtplib, mock_hook_conn):
201205

202206
@patch("smtplib.SMTP_SSL")
203207
@patch("smtplib.SMTP")
204-
def test_send_mime_ssl(self, mock_smtp, mock_smtp_ssl):
208+
@patch("ssl.create_default_context")
209+
def test_send_mime_ssl(self, create_default_context, mock_smtp, mock_smtp_ssl):
205210
mock_smtp_ssl.return_value = Mock()
206211
with SmtpHook() as smtp_hook:
207212
smtp_hook.send_email_smtp(to="to", subject="subject", html_content="content", from_email="from")
208213
assert not mock_smtp.called
209-
mock_smtp_ssl.assert_called_once_with(host="smtp_server_address", port=465, timeout=30)
214+
assert create_default_context.called
215+
mock_smtp_ssl.assert_called_once_with(
216+
host="smtp_server_address", port=465, timeout=30, context=create_default_context.return_value
217+
)
218+
219+
@patch("smtplib.SMTP_SSL")
220+
@patch("smtplib.SMTP")
221+
@patch("ssl.create_default_context")
222+
def test_send_mime_ssl_none_email_context(self, create_default_context, mock_smtp, mock_smtp_ssl):
223+
mock_smtp_ssl.return_value = Mock()
224+
with conf_vars({("smtp", "smtp_ssl"): "True", ("email", "ssl_context"): "none"}):
225+
with SmtpHook() as smtp_hook:
226+
smtp_hook.send_email_smtp(
227+
to="to", subject="subject", html_content="content", from_email="from"
228+
)
229+
assert not mock_smtp.called
230+
assert not create_default_context.called
231+
mock_smtp_ssl.assert_called_once_with(host="smtp_server_address", port=465, timeout=30, context=None)
232+
233+
@patch("smtplib.SMTP_SSL")
234+
@patch("smtplib.SMTP")
235+
@patch("ssl.create_default_context")
236+
def test_send_mime_ssl_none_smtp_provider_context(self, create_default_context, mock_smtp, mock_smtp_ssl):
237+
mock_smtp_ssl.return_value = Mock()
238+
with conf_vars({("smtp", "smtp_ssl"): "True", ("smtp_provider", "ssl_context"): "none"}):
239+
with SmtpHook() as smtp_hook:
240+
smtp_hook.send_email_smtp(
241+
to="to", subject="subject", html_content="content", from_email="from"
242+
)
243+
assert not mock_smtp.called
244+
assert not create_default_context.called
245+
mock_smtp_ssl.assert_called_once_with(host="smtp_server_address", port=465, timeout=30, context=None)
246+
247+
@patch("smtplib.SMTP_SSL")
248+
@patch("smtplib.SMTP")
249+
@patch("ssl.create_default_context")
250+
def test_send_mime_ssl_none_smtp_provider_default_email_context(
251+
self, create_default_context, mock_smtp, mock_smtp_ssl
252+
):
253+
mock_smtp_ssl.return_value = Mock()
254+
with conf_vars(
255+
{
256+
("smtp", "smtp_ssl"): "True",
257+
("email", "ssl_context"): "default",
258+
("smtp_provider", "ssl_context"): "none",
259+
}
260+
):
261+
with SmtpHook() as smtp_hook:
262+
smtp_hook.send_email_smtp(
263+
to="to", subject="subject", html_content="content", from_email="from"
264+
)
265+
assert not mock_smtp.called
266+
assert not create_default_context.called
267+
mock_smtp_ssl.assert_called_once_with(host="smtp_server_address", port=465, timeout=30, context=None)
268+
269+
@patch("smtplib.SMTP_SSL")
270+
@patch("smtplib.SMTP")
271+
@patch("ssl.create_default_context")
272+
def test_send_mime_ssl_default_smtp_provider_none_email_context(
273+
self, create_default_context, mock_smtp, mock_smtp_ssl
274+
):
275+
mock_smtp_ssl.return_value = Mock()
276+
with conf_vars(
277+
{
278+
("smtp", "smtp_ssl"): "True",
279+
("email", "ssl_context"): "none",
280+
("smtp_provider", "ssl_context"): "default",
281+
}
282+
):
283+
with SmtpHook() as smtp_hook:
284+
smtp_hook.send_email_smtp(
285+
to="to", subject="subject", html_content="content", from_email="from"
286+
)
287+
assert not mock_smtp.called
288+
assert create_default_context.called
289+
mock_smtp_ssl.assert_called_once_with(
290+
host="smtp_server_address", port=465, timeout=30, context=create_default_context.return_value
291+
)
210292

211293
@patch("smtplib.SMTP_SSL")
212294
@patch("smtplib.SMTP")
@@ -269,7 +351,10 @@ def test_send_mime_partial_failure(self, mock_smtp_ssl, mime_message_mock):
269351

270352
@patch("airflow.models.connection.Connection")
271353
@patch("smtplib.SMTP_SSL")
272-
def test_send_mime_custom_timeout_retrylimit(self, mock_smtp_ssl, connection_mock):
354+
@patch("ssl.create_default_context")
355+
def test_send_mime_custom_timeout_retrylimit(
356+
self, create_default_context, mock_smtp_ssl, connection_mock
357+
):
273358
mock_smtp_ssl().sendmail.side_effect = smtplib.SMTPServerDisconnected()
274359
custom_retry_limit = 10
275360
custom_timeout = 60
@@ -287,6 +372,10 @@ def test_send_mime_custom_timeout_retrylimit(self, mock_smtp_ssl, connection_moc
287372
with pytest.raises(smtplib.SMTPServerDisconnected):
288373
smtp_hook.send_email_smtp(to="to", subject="subject", html_content="content")
289374
mock_smtp_ssl.assert_any_call(
290-
host=fake_conn.host, port=fake_conn.port, timeout=fake_conn.extra_dejson["timeout"]
375+
host=fake_conn.host,
376+
port=fake_conn.port,
377+
timeout=fake_conn.extra_dejson["timeout"],
378+
context=create_default_context.return_value,
291379
)
380+
assert create_default_context.called
292381
assert mock_smtp_ssl().sendmail.call_count == 10

0 commit comments

Comments
 (0)