<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mojtaba Karimi</title>
    <description>The latest articles on DEV Community by Mojtaba Karimi (@mojtaba_pycode).</description>
    <link>https://dev.to/mojtaba_pycode</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4109301%2F0731a104-4b0f-48d1-a40d-4b507bf5934a.png</url>
      <title>DEV Community: Mojtaba Karimi</title>
      <link>https://dev.to/mojtaba_pycode</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mojtaba_pycode"/>
    <language>en</language>
    <item>
      <title>How I build web scrapers a business can actually rely on</title>
      <dc:creator>Mojtaba Karimi</dc:creator>
      <pubDate>Mon, 26 Oct 2026 15:49:27 +0000</pubDate>
      <link>https://dev.to/mojtaba_pycode/how-i-build-web-scrapers-a-business-can-actually-rely-on-lkm</link>
      <guid>https://dev.to/mojtaba_pycode/how-i-build-web-scrapers-a-business-can-actually-rely-on-lkm</guid>
      <description>&lt;p&gt;&lt;strong&gt;The problem.&lt;/strong&gt; A business wants to track prices across many online stores — competitors, suppliers, its own catalog. Checking by hand is slow and drifts out of date, and a naive scraper makes it worse: it gets blocked, it breaks the moment a page changes, and it hands back half-parsed HTML nobody can trust. The real question is never "can you scrape it." It is "can I rely on this data tomorrow, and next month."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I built.&lt;/strong&gt; A price-intelligence platform that scrapes products across stores, keeps a full price history, detects every change, and shows it on a dashboard and over an API. You can open it right now: &lt;a href="https://price-intelligence-demo.onrender.com" rel="noopener noreferrer"&gt;https://price-intelligence-demo.onrender.com&lt;/a&gt; (free instance, the first request takes about 40 seconds to wake). The code is public, with the test suite and CI badges next to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The decisions that make it reliable&lt;/strong&gt; — the part most scrapers skip:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Polite by default.&lt;/strong&gt; It reads &lt;code&gt;robots.txt&lt;/code&gt;, holds one request per second per host, and an SSRF guard resolves every URL and refuses private and internal addresses, with no switch to turn that off. This keeps the target site, and you, out of trouble.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Append-only history.&lt;/strong&gt; Every product carries its full trend line, its lowest-ever price, and a log of every detected change. Nothing is overwritten, so "when did this move, and by how much" always has an answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One file per store.&lt;/strong&gt; Adding a new shop is a single new plugin, with no change to the pipeline, the API or the database. The system grows without rewrites.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest data.&lt;/strong&gt; Tests run against fixtures, offline and deterministic, so the suite proves the parsing is correct instead of depending on a live shop that changes under it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Proof, not promises.&lt;/strong&gt; An 80% test-coverage floor is enforced in CI — the build fails if it drops. &lt;code&gt;ruff&lt;/code&gt;, &lt;code&gt;mypy&lt;/code&gt; and &lt;code&gt;bandit&lt;/code&gt; run on every push. And there is a live demo you can click. I do not ask you to take my word for any of it: the badges are live and the code is open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this means if you hire me.&lt;/strong&gt; You get clean, structured data — CSV, JSON, or straight into a database — plus the script and a short guide, so you can run it again yourself. It extends to new sources without a rewrite, and it tells you when something breaks instead of failing silently. Competitor prices, B2B lead lists, or any public dataset: the same discipline applies — sourced, checked, and something you can still trust next month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Want this for your data?&lt;/strong&gt; Tell me the site and the fields you need, and I will tell you honestly what is possible. Hire me on Upwork, Freelancer or Fiverr, or email &lt;a href="mailto:mojtaba.python@gmail.com"&gt;mojtaba.python@gmail.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webscraping</category>
      <category>datascience</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Python's smtplib doesn't verify TLS certificates by default</title>
      <dc:creator>Mojtaba Karimi</dc:creator>
      <pubDate>Fri, 04 Sep 2026 09:51:31 +0000</pubDate>
      <link>https://dev.to/mojtaba_pycode/pythons-smtplib-doesnt-verify-tls-certificates-by-default-1bla</link>
      <guid>https://dev.to/mojtaba_pycode/pythons-smtplib-doesnt-verify-tls-certificates-by-default-1bla</guid>
      <description>&lt;p&gt;I found this while writing the e-mail alert channel for a monitoring tool. The&lt;br&gt;
code looked fine, passed review, and was quietly insecure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;smtplib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SMTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;587&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;starttls&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# credentials, over an unauthenticated channel:
&lt;/span&gt;    &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendmail&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That connection is &lt;strong&gt;encrypted but not authenticated&lt;/strong&gt;. Anyone who can&lt;br&gt;
intercept the traffic can present any certificate they like, and Python will&lt;br&gt;
accept it — then send your SMTP username and password through the tunnel they&lt;br&gt;
control.&lt;/p&gt;
&lt;h2&gt;
  
  
  The proof
&lt;/h2&gt;

&lt;p&gt;Here is a local SMTP server presenting a self-signed certificate issued for&lt;br&gt;
&lt;code&gt;totally-not-your-mail-server.example&lt;/code&gt;, while the client connects to&lt;br&gt;
&lt;code&gt;127.0.0.1&lt;/code&gt;. The hostname doesn't match and no CA signed it — two independent&lt;br&gt;
reasons to reject it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1) client.starttls()                      # the default
   RESULT: handshake ACCEPTED
   -&amp;gt; the client trusted this certificate

2) client.starttls(context=ssl.create_default_context())
   RESULT: handshake REFUSED
   -&amp;gt; [SSL: CERTIFICATE_VERIFY_FAILED] self-signed certificate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default accepts a certificate that every browser on earth would refuse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;From CPython's &lt;code&gt;smtplib&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;starttls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_create_stdlib_context&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wrap_socket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;server_hostname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And what is &lt;code&gt;_create_stdlib_context&lt;/code&gt;? In &lt;code&gt;ssl.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_create_stdlib_context&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_create_unverified_context&lt;/span&gt;
&lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is an alias for &lt;code&gt;_create_unverified_context&lt;/code&gt;. The name says it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_create_stdlib_context&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;check_hostname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;verify_mode&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;VerifyMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CERT_NONE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_default_context&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;check_hostname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;verify_mode&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;VerifyMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CERT_REQUIRED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;create_default_context()&lt;/code&gt; — the one you have to ask for by name — is the safe&lt;br&gt;
one. The one you get by doing nothing is not.&lt;/p&gt;
&lt;h2&gt;
  
  
  It is not just STARTTLS
&lt;/h2&gt;

&lt;p&gt;I assumed implicit TLS would be fine. It isn't. &lt;code&gt;SMTP_SSL.__init__&lt;/code&gt; has the&lt;br&gt;
same default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_create_stdlib_context&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;SMTP_SSL(host, 465)&lt;/code&gt; — the "just use SMTPS, it's secure" option — is also&lt;br&gt;
unverified unless you pass a context. Both roads lead to the same place.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;One argument:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;smtplib&lt;/span&gt;

&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_default_context&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;      &lt;span class="c1"&gt;# verifies chain AND hostname
&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;smtplib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SMTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;587&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;starttls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For implicit TLS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;smtplib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SMTP_SSL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;465&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_default_context&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While you're there, one more guard worth having — refuse to send credentials at&lt;br&gt;
all if the session never got encrypted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;use_tls&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;use_ssl&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refusing to send SMTP credentials over cleartext&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A misconfiguration should cost you a failed alert, not a leaked password.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your own code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"starttls()"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"SMTP_SSL("&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any &lt;code&gt;starttls()&lt;/code&gt; with empty parentheses, or any &lt;code&gt;SMTP_SSL(...)&lt;/code&gt; without&lt;br&gt;
&lt;code&gt;context=&lt;/code&gt;, is unverified.&lt;/p&gt;

&lt;p&gt;This shows up a lot in the wild: password-reset mailers, CI notifiers, cron&lt;br&gt;
scripts, "send me an alert when the job fails" glue code. All of them&lt;br&gt;
authenticate to an SMTP server, which means all of them hand over a credential.&lt;/p&gt;
&lt;h2&gt;
  
  
  Is this a bug?
&lt;/h2&gt;

&lt;p&gt;Not exactly, and that's the interesting part.&lt;/p&gt;

&lt;p&gt;The docstring does hint at it — &lt;em&gt;"If you provide the context parameter, the&lt;br&gt;
identity of the SMTP server and client can be checked"&lt;/em&gt; — which, read&lt;br&gt;
carefully, tells you that without it, the identity is &lt;strong&gt;not&lt;/strong&gt; checked. But it&lt;br&gt;
is phrased as an added capability rather than a warning, and the safe behaviour&lt;br&gt;
is the one you have to opt into.&lt;/p&gt;

&lt;p&gt;The default is what it is for backward compatibility. &lt;code&gt;urllib&lt;/code&gt; and &lt;code&gt;httplib&lt;/code&gt;&lt;br&gt;
were fixed to verify by default back in Python 3.4.3 (PEP 476); &lt;code&gt;smtplib&lt;/code&gt; was&lt;br&gt;
left alone, largely because a great many mail servers ran self-signed or&lt;br&gt;
mismatched certificates and changing it would have broken them. That reasoning&lt;br&gt;
made sense in 2015. It has aged.&lt;/p&gt;

&lt;p&gt;The practical consequence today: TLS in Python's SMTP client protects you&lt;br&gt;
against a passive eavesdropper, and not at all against an active one. If your&lt;br&gt;
threat model includes someone who can redirect traffic — a hostile network, a&lt;br&gt;
compromised router, DNS poisoning — then the default gives you a comfortable&lt;br&gt;
feeling and no security.&lt;/p&gt;
&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Encrypted is not the same as authenticated. If a TLS API lets you connect&lt;br&gt;
without naming a context, find out which context it picked for you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_default_context&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# the one you want
&lt;/span&gt;&lt;span class="n"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_create_stdlib_context&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# the one you get
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;&lt;em&gt;Verified on CPython 3.12.5; the same code is present in current CPython. I ran into this while building &lt;a href="https://github.com/mojtaba-py-code/website-monitoring-automation" rel="noopener noreferrer"&gt;an open-source monitoring tool&lt;/a&gt; whose alerting sends e-mail — the fix and its regression tests are in &lt;code&gt;src/webmon/alerting/channels.py&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>security</category>
      <category>tls</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
