Testing TLS validation in smtplib with smtps-proxy
Python’s smtplib will happily negotiate TLS without validating the server certificate unless you pass an explicit SSL context. I verified this with a local SMTP proxy and confirmed that credentials can be intercepted.
This behavior is not a new discovery. The risks around unauthenticated TLS in Python standard library clients have been discussed for years in Python issues, documentation, PEPs, and multiple articles. There have also been many assigned CVEs for incorrect usage across projects. This post is meant as a demonstration of the problem and fix specifically in the SMTP STARTTLS case.
Security Advisories
As part of this investigation, I identified and reported insecure SMTP client configurations to the following upstream projects:
- CKAN
- CVE-2026-41132
- GHSA-mpfm-fpgx-647q
- 2026-04-08: Disclosed via security@ckan.org
- 2026-04-29: Fixed in ckan/ckan@2fbde62
- 2026-04-29: Announced in v.2.11.5 Changelog
- Apache Airflow (SMTP Provider)
- CVE-2026-41016
- GHSA-x8mh-94wc-33gv
- 2026-04-10: Disclosed via security@airflow.apache.org
- 2026-04-16: Fixed in apache/airflow@06981d4
- 2026-04-27: Announced in users@airflow.apache.org/CVE-2026-41016
- Apache Airflow (Core Airflow)
- CVE-2026-49267
- GHSA-799x-qp47-8qwq
- Same disclosure and fix as previous, new core release
- 2026-05-31: Announced in users@airflow.apache.org/CVE-2026-49267
Background
I’ve been using my watch-diff project for more than 10 years now to monitor command outputs and get email notifications on changes. It has run in different forms, in different places and has been pretty useful. Recently, I was browsing the smtplib documentation for fun and noticed this for the first time:
Please read Security considerations for best practices.
Which leads to the following:
For client use, if you don’t have any special requirements for your security policy, it is highly recommended that you use the
create_default_context()function to create your SSL context. It will load the system’s trusted CA certificates, enable certificate validation and hostname checking, and try to choose reasonably secure protocol and cipher settings.
It seems without this SSL default context, smtplib will not do any SSL validation at all. Without SSL validation, a person between the client and the server can provide a bogus certificate, decrypt and read the communication, forward it to the real server, all without any errors on the client side.
I wanted to test out if this were really the case and if my watch-diff tool was vulnerable.
Test setup with smtps-proxy
So I wrote a simple Go service that accepts connections on port 587, supports promoting connections to TLS using STARTTLS, returning a generated self-signed certificate, decrypts and reads the SMTP communication (including SMTP credentials) and forwards them to the real SMTP server.
To use it locally, I added 127.0.0.1 smtp.gmail.com to my /etc/hosts file. This way, whenever watch-diff opens a connection to the SMTP server, it is sent to the Go service instead. If SSL/TLS validation were working properly, the watch-diff program would crash with some sort of invalid certificate error. But it didn’t!
$ watch-diff -v -i 10 -r test@francisbergin.net date
INFO:watch_diff.__main__:executing command with time 2026-04-06 16:06:25.809820
[2026-04-06 16:06:25.809820] first_run:
Mon 6 Apr 2026 16:06:25 EDT
INFO:watch_diff.__main__:sending first_run email to test@francisbergin.net
INFO:watch_diff.email:sending email
INFO:watch_diff.email:running func: "_smtp_connect", count: 1
INFO:watch_diff.email:running func: "_smtp_login", count: 1
INFO:watch_diff.email:email sent successfully
INFO:watch_diff.__main__:sleeping for 10 seconds
$ smtps-proxy
2026/04/06 16:06:13 Starting SMTP server with STARTTLS on :587
2026/04/06 16:06:25 127.0.0.1:58938: NewSession
2026/04/06 16:06:25 127.0.0.1:58938: Generating certificate for SNI: smtp.gmail.com
2026/04/06 16:06:26 127.0.0.1:58938: Logout
2026/04/06 16:06:26 127.0.0.1:58938: NewSession
2026/04/06 16:06:26 127.0.0.1:58938: Auth credentials: identity= username=abc@gmail.com password=REDACTED
2026/04/06 16:06:26 127.0.0.1:58938: Connecting to real server: smtp.gmail.com (64.233.178.108)
2026/04/06 16:06:28 127.0.0.1:58938: Mail from: abc@gmail.com
2026/04/06 16:06:28 127.0.0.1:58938: Rcpt to: test@francisbergin.net
2026/04/06 16:06:28 127.0.0.1:58938: Data received
2026/04/06 16:06:29 127.0.0.1:58938: Data forwarded to real server (754 bytes)
2026/04/06 16:06:29 127.0.0.1:58938: Reset
2026/04/06 16:06:29 127.0.0.1:58938: Logout
As shown above, the service in the middle of the client and the server was able to capture credentials, even though TLS was used to encrypt communications!
Fix
When passing in the SSL default context into the starttls() function, as suggested in the Python SSL documentation, watch-diff will error out and quickly terminate the connection before any credentials or message content are sent:
Traceback (most recent call last):
...
s.starttls(context=context)
~~~~~~~~~~^^^^^^^^^^^^^^^^^
...
self.do_handshake()
~~~~~~~~~~~~~~~~~^^
...
self._sslobj.do_handshake()
~~~~~~~~~~~~~~~~~~~~~~~~~^^
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate (_ssl.c:1081)
This error happens during the STARTTLS handshake before any credentials are sent.
The full change required is very simple (francisbergin/watch-diff@ade48ad):
diff --git a/watch_diff/email.py b/watch_diff/email.py
index ec49613..e926f2f 100644
--- a/watch_diff/email.py
+++ b/watch_diff/email.py
@@ -2,6 +2,7 @@ import functools
import logging
import smtplib
import socket
+import ssl
import time
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
@@ -71,7 +72,8 @@ class Email:
s = self._smtp_connect(self._smtp_host, self._smtp_port)
s.ehlo()
- s.starttls()
+ context = ssl.create_default_context()
+ s.starttls(context=context)
s.ehlo()
self._smtp_login(s, self._smtp_user, self._smtp_pass)
s.sendmail(self._smtp_user, self._recipient, msg.as_string())
Prior work
These are a few of the related discussions that happened before:
- Openwall discussion (2024)
- Pentagrid Article (2023)
- python/cpython#91826 (2022)
- PEP 476 (2014)
- LWN.net Article (2014)
Takeaways
This vulnerability was part of the very first version of this project. Throughout the years, it has run in different VPS environments. I was always under the impression that since the script was using TLS to communicate with the server, my credentials were safe. This was not the case. If an attacker had access to the network between the client and the server, they could have easily read the SMTP credentials and used them to send emails on my behalf.
This is a good reminder that just because a connection is encrypted, it doesn’t necessarily mean it’s secure. Proper SSL/TLS validation is important to ensure the security of communications.