VYPR
Unrated severityNVD Advisory· Published Jun 1, 2026

CVE-2026-49267

CVE-2026-49267

Description

Apache Airflow's EmailOperator fails to verify SMTP server certificate on STARTTLS, enabling MITM credential theft.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Apache Airflow's EmailOperator fails to verify SMTP server certificate on STARTTLS, enabling MITM credential theft.

Vulnerability

Apache Airflow's EmailOperator and the underlying airflow.utils.email helpers establish SMTP STARTTLS connections without verifying the remote certificate when the deployment uses [email] smtp_starttls=True without [email] smtp_ssl. The smtplib.SMTP.starttls() function was called without an SSL context, so the STARTTLS upgrade accepted any certificate [1]. This affects all apache-airflow versions prior to 3.2.2. The same root cause in the SMTP provider is covered by CVE-2026-41016, but the core code path required a separate fix.

Exploitation

An attacker positioned between the Airflow worker and the configured SMTP server (network MITM) can present a self-signed certificate during the STARTTLS handshake. Because no validation occurs, the worker completes the handshake and subsequently sends SMTP AUTH credentials and email contents over the attacker-controlled connection. No special authentication or user interaction is required beyond the MITM access.

Impact

A successful attacker captures SMTP credentials (username and password) and the full content of emails sent by the worker. This leads to information disclosure and may enable further compromise of email accounts or infrastructure.

Mitigation

Upgrade apache-airflow to version 3.2.2 or later, which passes an SSL context to starttls() (defaulting to ssl.create_default_context()) [1]. Users who intentionally use self-signed certificates can preserve the old behavior by setting [email] ssl_context to "none". The SMTP provider fix from CVE-2026-41016 should also be applied.

AI Insight generated on Jun 1, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

2
06981d4ad824

Validate SMTP server certificate on STARTTLS upgrade (#65346)

https://github.com/apache/airflowJarek PotiukApr 16, 2026via nvd-ref
5 files changed · +79 32
  • airflow-core/newsfragments/65346.significant.rst+3 0 added
    @@ -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.
    
  • airflow-core/src/airflow/utils/email.py+22 12 modified
    @@ -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]:
    
  • providers/smtp/docs/changelog.rst+13 0 modified
    @@ -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
     .....
    
  • providers/smtp/src/airflow/providers/smtp/hooks/smtp.py+23 10 modified
    @@ -126,7 +126,7 @@ def get_conn(self) -> SmtpHook:
                             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 @@ async def aget_conn(self) -> SmtpHook:
                             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 @@ async def aget_conn(self) -> SmtpHook:
     
             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 @@ def _build_client_kwargs(self, is_async: bool) -> dict[str, Any]:
                 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
     
    
  • providers/smtp/tests/unit/smtp/hooks/test_smtp.py+18 10 modified
    @@ -20,6 +20,7 @@
     import json
     import os
     import smtplib
    +import ssl
     import tempfile
     from email.mime.application import MIMEApplication
     from unittest import mock
    @@ -535,9 +536,15 @@ def test_ehlo_called_after_starttls(self, mock_smtplib):
             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 @@ async def test_async_connection(
             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
    
cde4885818be

Updating release notes for 3.2.2rc3

https://github.com/apache/airflowvatsrahul1001May 26, 2026Fixed in 3.2.2via release-tag
2 files changed · +5 4
  • RELEASE_NOTES.rst+3 2 modified
    @@ -24,7 +24,7 @@
     
     .. towncrier release notes start
     
    -Airflow 3.2.2 (2026-05-27)
    +Airflow 3.2.2 (2026-05-29)
     --------------------------
     
     Significant Changes
    @@ -81,7 +81,8 @@ Significant Changes
     
     Bug Fixes
     ^^^^^^^^^
    -
    +- Fix ``Callback.handle_event`` triggerer crash when OpenTelemetry metrics receive dict typed tag values (#67527) (#67529)
    +- UI: Rewrite ``modulepreload hrefs`` to the api-server static path (#67548) (#67556)
     - Correctly pre-allocate ``external_executor_id`` with multiple executors on PostgreSQL (#67388) (#67458)
     - Return raw import-error stacktrace when a Dag file has no registered Dag (#67465) (#67478)
     - UI: Fix Expand/Collapse All on XComs and Audit Log JSON cells (#67316) (#67361)
    
  • reproducible_build.yaml+2 2 modified
    @@ -1,2 +1,2 @@
    -release-notes-hash: 6407b48d1054fe3ce68c09bf4435d91d
    -source-date-epoch: 1779745327
    +release-notes-hash: 504288db9a9dc13a0db859232fab98d0
    +source-date-epoch: 1779811737
    

Vulnerability mechanics

Root cause

"`smtplib.SMTP.starttls()` was called without an SSL context, so the STARTTLS upgrade accepted any certificate presented by the server."

Attack vector

An attacker positioned as a network man-in-the-middle between the Airflow worker and the configured SMTP server can present a self-signed or otherwise invalid certificate during the STARTTLS handshake. Because `smtplib.SMTP.starttls()` was called without an SSL context, the worker accepted any certificate silently. The attacker can then capture SMTP AUTH credentials and email message contents transmitted over the attacker-terminated connection. This affects deployments using `smtp_starttls=True` without `smtp_ssl=True` where the SMTP relay is reachable across a less-trusted network segment [CWE-295].

Affected code

The vulnerability resides in `airflow/utils/email.py` in the `send_mime_email` function, which called `smtp_conn.starttls()` without an SSL context. The same pattern existed in the SMTP provider's `hooks/smtp.py` in both `get_conn` and `aget_conn` methods. The patch introduces a shared `_get_ssl_context()` helper and passes its result to `starttls()` at all three call sites.

What the fix does

The patch passes 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 the server certificate against the system's trusted CA bundle. Users who intentionally use self-signed certificates can preserve the old behavior by setting the value to `"none"` [patch_id=4190113]. The same change is applied to both the core `airflow.utils.email` path and the SMTP provider's sync and async hooks.

Preconditions

  • configAirflow deployment configured with `[email] smtp_starttls=True` and `[email] smtp_ssl=False`
  • networkSMTP relay is reachable across a network segment the attacker can access (hostile-network attack surface)

Generated on Jun 1, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.