You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by po...@apache.org on 2026/04/16 10:01:15 UTC

(airflow) branch main updated: Validate SMTP server certificate on STARTTLS upgrade (#65346)

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 06981d4ad82 Validate SMTP server certificate on STARTTLS upgrade (#65346)
06981d4ad82 is described below

commit 06981d4ad8249bfd371bd718d37c7f04eec7c151
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Thu Apr 16 12:01:07 2026 +0200

    Validate SMTP server certificate on STARTTLS upgrade (#65346)
    
    * Validate SMTP server certificate on STARTTLS upgrade
    
    smtplib.SMTP.starttls() does not validate the server certificate
    unless an SSL context is passed. airflow.utils.email.send_mime_email
    and the SMTP provider's SmtpHook (both sync get_conn and async
    aget_conn) were calling starttls() without a context, so the STARTTLS
    upgrade accepted any certificate and the subsequent login() call
    could send credentials over a connection terminated by a MITM.
    
    Pass the existing SSL-context machinery (the email.ssl_context
    config in core and the ssl_context connection extra in the provider)
    to starttls() at all three call sites. The default becomes
    ssl.create_default_context(), which validates against the system's
    trusted CAs. Users who intentionally use self-signed certificates
    can still opt out by setting the value to "none".
    
    Generated-by: Claude Opus 4.6 (1M context) following the guidelines at
    https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions
    
    * Add newsfragment and SMTP provider changelog for STARTTLS cert default
    
    Document the default behaviour change introduced by passing an SSL
    context to the STARTTLS upgrade: system-default CA validation now
    applies to both airflow.utils.email.send_email (via
    email.ssl_context) and the SMTP provider's SmtpHook (via the
    ssl_context connection extra). Users who intentionally run against
    self-signed SMTP servers can preserve the old behaviour by setting
    the value to "none".
    
    Generated-by: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
 airflow-core/newsfragments/65346.significant.rst   |  3 ++
 airflow-core/src/airflow/utils/email.py            | 34 ++++++++++++++--------
 providers/smtp/docs/changelog.rst                  | 13 +++++++++
 .../smtp/src/airflow/providers/smtp/hooks/smtp.py  | 33 ++++++++++++++-------
 providers/smtp/tests/unit/smtp/hooks/test_smtp.py  | 28 +++++++++++-------
 5 files changed, 79 insertions(+), 32 deletions(-)

diff --git a/airflow-core/newsfragments/65346.significant.rst b/airflow-core/newsfragments/65346.significant.rst
new file mode 100644
index 00000000000..bdfc390c31c
--- /dev/null
+++ b/airflow-core/newsfragments/65346.significant.rst
@@ -0,0 +1,3 @@
+The SMTP STARTTLS upgrade performed by ``airflow.utils.email.send_email`` now validates the SMTP server's certificate against the system's trusted CA bundle by default. Previously the ``starttls()`` call was made without an SSL context, so any certificate was accepted.
+
+Deployments that intentionally point Airflow at an SMTP server with a self-signed or otherwise non-validating certificate and need to preserve the previous behaviour must set ``email.ssl_context = "none"`` in ``airflow.cfg``. The ``"default"`` value (now also the default when the option is unset) uses :func:`ssl.create_default_context`. Previously this option applied only to the ``SMTP_SSL`` path; it now applies to the STARTTLS path as well.
diff --git a/airflow-core/src/airflow/utils/email.py b/airflow-core/src/airflow/utils/email.py
index e269b483386..7376e14baf8 100644
--- a/airflow-core/src/airflow/utils/email.py
+++ b/airflow-core/src/airflow/utils/email.py
@@ -266,7 +266,7 @@ def send_mime_email(
                     raise
             else:
                 if smtp_starttls:
-                    smtp_conn.starttls()
+                    smtp_conn.starttls(context=_get_ssl_context())
                 if smtp_user and smtp_password:
                     smtp_conn.login(smtp_user, smtp_password)
                 log.info("Sent an alert email to %s", e_to)
@@ -292,6 +292,26 @@ def get_email_address_list(addresses: str | Iterable[str]) -> list[str]:
     raise TypeError(f"Unexpected argument type: Received '{type(addresses).__name__}'.")
 
 
+def _get_ssl_context() -> ssl.SSLContext | None:
+    """
+    Return the SSL context configured via the ``email.ssl_context`` option.
+
+    ``"default"`` produces :func:`ssl.create_default_context`; ``"none"``
+    returns ``None`` so callers that explicitly want to skip certificate
+    validation (for example, against a self-signed SMTP server in a
+    lab environment) can still do so.
+    """
+    ssl_context_string = conf.get("email", "SSL_CONTEXT")
+    if ssl_context_string == "default":
+        return ssl.create_default_context()
+    if ssl_context_string == "none":
+        return None
+    raise RuntimeError(
+        f"The email.ssl_context configuration variable must "
+        f"be set to 'default' or 'none' and is '{ssl_context_string}."
+    )
+
+
 def _get_smtp_connection(host: str, port: int, timeout: int, with_ssl: bool) -> smtplib.SMTP:
     """
     Return an SMTP connection to the specified host and port, with optional SSL encryption.
@@ -304,17 +324,7 @@ def _get_smtp_connection(host: str, port: int, timeout: int, with_ssl: bool) ->
     """
     if not with_ssl:
         return smtplib.SMTP(host=host, port=port, timeout=timeout)
-    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)
+    return smtplib.SMTP_SSL(host=host, port=port, timeout=timeout, context=_get_ssl_context())
 
 
 def _get_email_list_from_str(addresses: str) -> list[str]:
diff --git a/providers/smtp/docs/changelog.rst b/providers/smtp/docs/changelog.rst
index 9208a397314..b29c1086233 100644
--- a/providers/smtp/docs/changelog.rst
+++ b/providers/smtp/docs/changelog.rst
@@ -27,6 +27,19 @@
 Changelog
 ---------
 
+Breaking changes
+~~~~~~~~~~~~~~~~
+
+The SMTP STARTTLS upgrade performed by ``SmtpHook.get_conn`` and ``SmtpHook.aget_conn`` now
+validates the SMTP server's certificate against the system's trusted CA bundle by default.
+Previously the ``starttls()`` call was made without an SSL context, so any certificate was
+accepted.
+
+Deployments that intentionally point ``SmtpHook`` at an SMTP server with a self-signed or
+otherwise non-validating certificate and need to preserve the previous behaviour must set the
+``ssl_context`` field in the SMTP connection extras to ``"none"``. Leaving the field unset (or
+setting it to ``"default"``) now applies ``ssl.create_default_context()`` to the STARTTLS
+upgrade as well as to the existing ``SMTP_SSL`` path.
 
 2.4.5
 .....
diff --git a/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py b/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py
index b44010f0c17..4f866d6010e 100644
--- a/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py
+++ b/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py
@@ -126,7 +126,7 @@ class SmtpHook(BaseHook):
                         raise AirflowException("Unable to connect to smtp server")
                 else:
                     if self.smtp_starttls:
-                        self._smtp_client.starttls()
+                        self._smtp_client.starttls(context=self._build_ssl_context())
                         self._smtp_client.ehlo()
 
                     # choose auth
@@ -172,7 +172,7 @@ class SmtpHook(BaseHook):
                         raise AirflowException("Unable to connect to smtp server")
                 else:
                     if self.smtp_starttls:
-                        await async_client.starttls()
+                        await async_client.starttls(tls_context=self._build_ssl_context())
                         await async_client.ehlo()
 
                     # choose auth
@@ -191,10 +191,27 @@ class SmtpHook(BaseHook):
 
         return self
 
-    def _build_client_kwargs(self, is_async: bool) -> dict[str, Any]:
-        """Build kwargs appropriate for sync or async SMTP client."""
+    def _build_ssl_context(self) -> ssl.SSLContext | None:
+        """
+        Return the SSL context configured via the ``ssl_context`` connection extra.
+
+        The default (unset or ``"default"``) returns
+        :func:`ssl.create_default_context`, which validates the server
+        certificate against the system's trusted CAs. ``"none"`` returns
+        ``None`` so callers that explicitly want to skip validation (for
+        example, against a self-signed SMTP server in a lab environment)
+        can opt out.
+        """
         valid_contexts = (None, "default", "none")  # Values accepted for ssl_context configuration
+        if self.ssl_context not in valid_contexts:
+            raise RuntimeError(
+                f"The connection extra field `ssl_context` must "
+                f"be set to 'default' or 'none' but it is set to '{self.ssl_context}'."
+            )
+        return None if self.ssl_context == "none" else ssl.create_default_context()
 
+    def _build_client_kwargs(self, is_async: bool) -> dict[str, Any]:
+        """Build kwargs appropriate for sync or async SMTP client."""
         kwargs: dict[str, Any] = {"timeout": self.timeout}
 
         if self.port:
@@ -204,15 +221,11 @@ class SmtpHook(BaseHook):
             kwargs["hostname"] = self.host
             kwargs["use_tls"] = self.use_ssl
             kwargs["start_tls"] = self.smtp_starttls if not self.use_ssl else None
+            kwargs["tls_context"] = self._build_ssl_context()
         else:
             kwargs["host"] = self.host
             if self.use_ssl:
-                if self.ssl_context not in valid_contexts:
-                    raise RuntimeError(
-                        f"The connection extra field `ssl_context` must "
-                        f"be set to 'default' or 'none' but it is set to '{self.ssl_context}'."
-                    )
-                kwargs["context"] = None if self.ssl_context == "none" else ssl.create_default_context()
+                kwargs["context"] = self._build_ssl_context()
 
         return kwargs
 
diff --git a/providers/smtp/tests/unit/smtp/hooks/test_smtp.py b/providers/smtp/tests/unit/smtp/hooks/test_smtp.py
index fcf6d5b30f5..0eb74b8999d 100644
--- a/providers/smtp/tests/unit/smtp/hooks/test_smtp.py
+++ b/providers/smtp/tests/unit/smtp/hooks/test_smtp.py
@@ -20,6 +20,7 @@ from __future__ import annotations
 import json
 import os
 import smtplib
+import ssl
 import tempfile
 from email.mime.application import MIMEApplication
 from unittest import mock
@@ -535,9 +536,15 @@ class TestSmtpHook:
         with SmtpHook(smtp_conn_id=CONN_ID_NONSSL):
             pass
 
-        # Verify ehlo is called after starttls and before login
-        expected_calls = [call.starttls(), call.ehlo(), call.login(SMTP_LOGIN, SMTP_PASSWORD)]
-        assert manager.mock_calls == expected_calls
+        # Verify ehlo is called after starttls and before login,
+        # and starttls is invoked with an SSL context so certificate validation
+        # happens on the TLS upgrade.
+        assert len(manager.mock_calls) == 3
+        starttls_call, ehlo_call, login_call = manager.mock_calls
+        assert starttls_call[0] == "starttls"
+        assert isinstance(starttls_call.kwargs.get("context"), ssl.SSLContext)
+        assert ehlo_call == call.ehlo()
+        assert login_call == call.login(SMTP_LOGIN, SMTP_PASSWORD)
 
 
 @pytest.mark.asyncio
@@ -626,13 +633,14 @@ class TestSmtpHookAsync:
         async with SmtpHook(smtp_conn_id=conn_id) as hook:
             assert hook is not None
 
-        mock_smtp.assert_called_once_with(
-            hostname=SMTP_HOST,
-            port=expected_port,
-            timeout=DEFAULT_TIMEOUT,
-            use_tls=expected_ssl,
-            start_tls=None if expected_ssl else True,
-        )
+        mock_smtp.assert_called_once()
+        call_kwargs = mock_smtp.call_args.kwargs
+        assert call_kwargs["hostname"] == SMTP_HOST
+        assert call_kwargs["port"] == expected_port
+        assert call_kwargs["timeout"] == DEFAULT_TIMEOUT
+        assert call_kwargs["use_tls"] == expected_ssl
+        assert call_kwargs["start_tls"] == (None if expected_ssl else True)
+        assert isinstance(call_kwargs["tls_context"], ssl.SSLContext)
 
         if expected_ssl:
             assert mock_smtp_client.starttls.await_count == 1