Test SMTP with STARTTLS

Run node probe.js 127.0.0.1:2526 against your own server. It prints the capability list, issues STARTTLS and verifies the certificate. A server that advertises STARTTLS still accepts a full cleartext session, so what this check reports is what the client did, not what the server offered.

Why check this

Run this whenever the mail path changes, and once per release on any service that authenticates to a relay. STARTTLS is opportunistic: encryption starts only if the client asks for it, and the client asks only if the capability was in the EHLO reply. Both halves can fail quietly.

The failure it prevents: a proxy between the application and the relay removes one line from the capability list, the mail library stops asking for TLS, and the AUTH LOGIN that follows carries the relay password in base64 over a cleartext connection. Nothing errors. Mail keeps arriving. Step 5 does that in 20 lines of proxy.

Prerequisites

// A TCP proxy that deletes the STARTTLS capability from the EHLO reply and
// forwards everything else unchanged. This is a STARTTLS stripping attack in
// 20 lines, run against your own server on the loopback address.
const net = require('net');
net.createServer((client) => {
  const upstream = net.createConnection({ host: '127.0.0.1', port: 2526 });
  client.pipe(upstream);
  upstream.on('data', (chunk) => {
    const text = chunk.toString('binary');
    client.write(Buffer.from(text.replace(/250-STARTTLS\r\n/g, ''), 'binary'));
  });
  client.on('error', () => upstream.destroy());
  upstream.on('error', () => client.destroy());
  upstream.on('close', () => client.end());
}).listen(2527, '127.0.0.1', function () {
  console.log('stripping proxy on 127.0.0.1:2527 -> 2526, pid ' + process.pid);
});
// The same server with mandatory TLS: no mail is accepted on a cleartext session.
const fs = require('fs');
const { SMTPServer } = require('smtp-server');
const server = new SMTPServer({
  name: 'mx-strict.h2check.test',
  disableReverseLookup: true,
  key: fs.readFileSync('smtp.key'),
  cert: fs.readFileSync('smtp.crt'),
  authOptional: true,
  onMailFrom(address, session, callback) {
    if (!session.secure) {
      const err = new Error('Must issue a STARTTLS command first');
      err.responseCode = 530;
      return callback(err);
    }
    callback();
  },
  onData(stream, session, callback) { stream.on('end', callback); stream.resume(); },
});
server.listen(2529, '127.0.0.1', () =>
  console.log('strict smtpd on 127.0.0.1:2529, pid ' + process.pid));
// Opens an SMTP session, prints the capability list, runs STARTTLS when the
// server offers it, prints the list again, and reports the certificate check.
// node probe.js 127.0.0.1:2526 [--insecure]
const net = require('net');
const tls = require('tls');
const [host, port] = (process.argv[2] || '127.0.0.1:2526').split(':');
const insecure = process.argv.includes('--insecure');

function session(sock, label) {
  return new Promise((resolve) => {
    let buf = '';
    const lines = [];
    sock.setEncoding('utf8');
    sock.on('data', function onData(d) {
      buf += d;
      let i;
      while ((i = buf.indexOf('\r\n')) !== -1) {
        const line = buf.slice(0, i); buf = buf.slice(i + 2);
        lines.push(line);
        if (/^\d{3} /.test(line)) {
          if (/^220 /.test(lines[0]) && lines.length === 1) { sock.write('EHLO tester.local\r\n'); continue; }
          sock.removeListener('data', onData);
          const caps = lines.filter((l) => /^250[- ]/.test(l)).slice(1).map((l) => l.slice(4));
          console.log(label + ' capabilities: ' + caps.join(' | '));
          return resolve({ caps, sock });
        }
      }
    });
  });
}

(async () => {
  const plain = net.createConnection({ host, port: Number(port) });
  const { caps } = await session(plain, 'cleartext');
  if (!caps.includes('STARTTLS')) {
    console.log('STARTTLS not offered, continuing in the clear');
    plain.write('QUIT\r\n'); plain.end();
    return;
  }
  plain.write('STARTTLS\r\n');
  await new Promise((r) => plain.once('data', (d) => { console.log('STARTTLS -> ' + d.trim()); r(); }));
  const secure = tls.connect({ socket: plain, servername: 'mx.h2check.test', rejectUnauthorized: !insecure });
  secure.on('error', (e) => console.log('TLS refused: ' + e.code + ' ' + (e.reason || e.message)));
  secure.on('secureConnect', () => {
    const cert = secure.getPeerCertificate();
    console.log('TLS ' + secure.getProtocol() + '  authorized=' + secure.authorized +
      (secure.authorized ? '' : '  reason=' + secure.authorizationError));
    console.log('peer subject=' + cert.subject.CN + '  issuer=' + cert.issuer.CN + '  notAfter=' + cert.valid_to);
    secure.write('EHLO tester.local\r\n');
    let buf = '';
    secure.setEncoding('utf8');
    secure.on('data', (d) => {
      buf += d;
      if (/\r\n250 [^\r\n]*\r\n$/.test(buf)) {
        const caps2 = buf.trim().split('\r\n').filter((l) => /^250[- ]/.test(l)).slice(1).map((l) => l.slice(4));
        console.log('encrypted capabilities: ' + caps2.join(' | '));
        secure.write('QUIT\r\n'); secure.end();
      }
    });
  });
})();
// Default nodemailer options, one recipient, two ports: the server itself and
// the same server behind a proxy that removed STARTTLS from the capability list.
const nodemailer = require('nodemailer');
(async () => {
  for (const port of [2526, 2527]) {
    const t = nodemailer.createTransport({ host: '127.0.0.1', port });
    try {
      const i = await t.sendMail({ from: 'ci@build.test', to: 'qa@build.test', subject: 'downgrade probe', text: 'x' });
      console.log('port ' + port + ': ' + i.response);
    } catch (e) {
      console.log('port ' + port + ': send failed, ' + e.code + ' ' + e.message);
    }
    t.close();
  }
})();

Steps

  1. Step 1.

    Start the proxy and the strict server next to the one already on 2526.

    node strip.js & node tlsd.js &
    
    stripping proxy on 127.0.0.1:2527 -> 2526, pid 42892
    strict smtpd on 127.0.0.1:2529, pid 25176

    Three ports now answer: the server, the same server with an edited capability list, and one that refuses mail on a cleartext session.

  2. Step 2.

    Probe the server with certificate verification on.

    node probe.js 127.0.0.1:2526
    
    cleartext capabilities: PIPELINING | 8BITMIME | SMTPUTF8 | AUTH LOGIN PLAIN | STARTTLS | SIZE 10485760
    STARTTLS -> 220 Ready to start TLS
    TLS refused: DEPTH_ZERO_SELF_SIGNED_CERT self-signed certificate

    The server offered the extension, the upgrade was accepted, and the client dropped the connection because the certificate is self-signed. This is the only outcome in the procedure where the session both encrypts and is checked.

  3. Step 3.

    Repeat with verification off and compare the two capability lists.

    node probe.js 127.0.0.1:2526 --insecure
    
    cleartext capabilities: PIPELINING | 8BITMIME | SMTPUTF8 | AUTH LOGIN PLAIN | STARTTLS | SIZE 10485760
    STARTTLS -> 220 Ready to start TLS
    TLS TLSv1.3  authorized=false  reason=DEPTH_ZERO_SELF_SIGNED_CERT
    peer subject=mx.h2check.test  issuer=mx.h2check.test  notAfter=Oct 11 20:55:24 2026 GMT
    encrypted capabilities: PIPELINING | 8BITMIME | SMTPUTF8 | AUTH LOGIN PLAIN | SIZE 10485760

    authorized=false on an encrypted session is the state most SMTP clients ship in. The traffic is unreadable to a passive listener and unauthenticated against an active one. STARTTLS is absent from the encrypted list, because the extension is spent once used.

  4. Step 4.

    Probe the same server through the proxy.

    node probe.js 127.0.0.1:2527 --insecure
    
    cleartext capabilities: PIPELINING | 8BITMIME | SMTPUTF8 | AUTH LOGIN PLAIN | SIZE 10485760
    STARTTLS not offered, continuing in the clear

    One line was deleted from the reply. The client behaves correctly for a server with no TLS, and there is no error anywhere to notice. AUTH LOGIN is still advertised on that session.

  5. Step 5.

    Send the same message through a mail library on both ports.

    node nm-downgrade.js
    
    port 2526: send failed, ESOCKET self-signed certificate
    port 2527: 250 OK: message queued

    Read that pair twice. On the honest server the send fails, because the library upgraded and then refused the certificate. Behind the proxy the same library, the same options and the same message deliver without a word, in the clear. Removing the capability turned a hard failure into a silent success.

  6. Step 6.

    Ask openssl the same question about the stripped port.

    printf 'QUIT\r\n' | openssl s_client -starttls smtp -connect 127.0.0.1:2527 -brief
    
    Didn't find STARTTLS in server response, trying anyway...
    Can't use SSL_get_servername
    depth=0 CN = mx.h2check.test
    verify error:num=18:self-signed certificate
    CONNECTION ESTABLISHED
    Protocol version: TLSv1.3
    Ciphersuite: TLS_AES_256_GCM_SHA384
    Peer certificate: CN = mx.h2check.test
    …
    Verification error: self-signed certificate
    Server Temp Key: X25519, 253 bits
    250 SIZE 10485760
    DONE

    openssl reports an encrypted session on the port where the mail library sent in the clear. It says why in its first line: it did not find the capability and issued the command anyway. As a downgrade detector, s_client gives the wrong answer.

  7. Step 7.

    Check what mandatory encryption looks like from the client side.

    node talk.js 127.0.0.1:2529 "EHLO tester.local" "MAIL FROM:<ci@build.test>" QUIT
    
    -- tcp connected to 127.0.0.1:2529
    S: 220 mx-strict.h2check.test ESMTP
    C: EHLO tester.local
    S: 250-mx-strict.h2check.test Nice to meet you, [127.0.0.1]
    …
    S: 250 STARTTLS
    C: MAIL FROM:<ci@build.test>
    S: 530 Must issue a STARTTLS command first
    C: QUIT
    S: 221 Bye
    -- connection closed

    A refusal at MAIL FROM is what a stripped capability should cost. The proxy in step 5 cannot produce a silent downgrade against this server: the message stops instead.

  8. Step 8.

    Stop the two processes you started and confirm the ports are free.

    powershell -Command "Stop-Process -Id 42892 -Force; Stop-Process -Id 25176 -Force"
    
    $ netstat -ano | grep LISTENING | grep -E ":(2527|2529) "
    $

    A stripping proxy left listening is worse than a stale test server.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | STARTTLS in the cleartext list, 220 Ready to start TLS | The extension works | Continue to the certificate check. | | TLS refused: DEPTH_ZERO_SELF_SIGNED_CERT | The client verified and the certificate failed | Fix the certificate. Do not switch verification off to make the test pass. | | authorized=false on a completed session | Encrypted and unauthenticated | An active attacker can present any certificate. Turn verification on in the client. | | STARTTLS not offered, continuing in the clear | Either the server has no TLS, or something removed the line | Compare with a probe from another network path. | | 530 after MAIL FROM | The server requires encryption | Correct for a submission service, and a delivery failure on a public MX. | | AUTH advertised on the cleartext list | Credentials can be sent before encryption | Configure the server to publish AUTH only after STARTTLS. | | openssl says Didn't find STARTTLS in server response | The capability was absent and openssl tried anyway | Trust the capability list, not the handshake that follows. |

Common mistakes

Sign: A downgrade is tested with openssl s_client and the test passes.Cause: s_client issues STARTTLS whether or not the server advertised it, and prints Didn't find STARTTLS in server response, trying anyway before doing so. In step 6 it reports TLSv1.3 on the same port where a mail library sent in the clear. Read the capability list with a client that honours it.
Sign: Verification errors are switched off in the client config so the send works.Cause: rejectUnauthorized: false leaves the session encrypted and unauthenticated, which is step 3. A passive listener is stopped, an active one is not, and the setting usually reaches production in the same commit as the fix that needed it.
Sign: Nobody notices that TLS stopped, because mail keeps being delivered.Cause: Opportunistic STARTTLS has no failure state when the capability is missing. Step 5 shows the same library delivering in the clear after one line was removed from the reply. Assert on the negotiated protocol in the send result, or set the client to require TLS so the failure becomes visible.
Sign: The public MX is configured to require STARTTLS and mail from some senders disappears.Cause: RFC 3207 section 4 forbids a publicly referenced SMTP server from requiring the extension for delivery: a sender that cannot negotiate has nowhere to go. Require it on the submission service on port 587, and publish MTA-STS for the MX instead.

What to check next

FAQ

Does STARTTLS encrypt SMTP?

Only when the client asks. The session starts in the clear, the client sends STARTTLS, and everything after the handshake is encrypted. A client that does not ask, or that never saw the capability, sends the whole message unencrypted and the server accepts it.

How do I check the certificate on a STARTTLS session?

Connect with verification on, as in step 2, and read the failure. openssl s_client -starttls smtp prints the chain and the verify result, and Node's tls.connect reports authorized and authorizationError for the same session.

Is port 465 safer than STARTTLS on 587?

Port 465 is TLS from the first byte, so there is no plaintext phase to strip. Port 587 with a client that requires STARTTLS and verifies the certificate reaches the same place. The weak setup is neither of those: opportunistic upgrade with verification off.

Why does my client report TLS while a capture shows plaintext?

They are looking at different hops. A proxy can encrypt to the relay and read the traffic on the way. Test from the host the application runs on, not from your laptop.

Verified

Verified by Maks Vernynode 22.23.2openssl 3.1.1nodemailer 10.0.8smtp-server 3.19.11

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

advanced12 minpublished updated Maks Verny