Skip to content

feat: support STARTTLS on plaintext listeners#8

Merged
iamd3vil merged 1 commit into
mainfrom
feat/starttls-support
Jul 15, 2026
Merged

feat: support STARTTLS on plaintext listeners#8
iamd3vil merged 1 commit into
mainfrom
feat/starttls-support

Conversation

@iamd3vil

Copy link
Copy Markdown
Owner

Summary

Adds STARTTLS support (RFC 3207). Listeners with TLS config now take a mode field:

[[server.listeners]]
addr = "0.0.0.0:587"
[server.listeners.tls]
cert_path = "/etc/ssl/certs/smtp.crt"
key_path = "/etc/ssl/private/smtp.key"
mode = "starttls"   # "implicit" is the default, so existing configs are unchanged
  • implicit (default): TLS handshake before any SMTP traffic — existing behavior for port 465.
  • starttls: connection starts in plaintext, server advertises 250-STARTTLS, and the session upgrades to TLS when the client issues STARTTLS — for submission on port 587.

What was broken

The STARTTLS path in the smtp crate was dead code (see PRODUCTION_HARDENING.md item 8, now marked addressed):

  1. The parser only matched "STARTTLS\r\n", but the read loop strips CRLF before parsing, so real clients always got 500 Syntax error.
  2. main.rs never gave the session a TLS acceptor, so STARTTLS was never advertised.
  3. The upgrade duplicated the raw fd via unsafe { from_raw_fd }, creating two owners of the same socket (double-close).

Implementation

  • New smtp::MaybeTlsStream enum upgrades plain → TLS in place via mem::replace — no unsafe, no recursive session handling. The acceptor travels with the stream, so each listener independently decides whether to offer STARTTLS (different certs per listener keep working).
  • STARTTLS is rejected with 502 when unavailable or already on TLS, 503 mid-transaction.
  • Security: bytes pipelined after the STARTTLS command are discarded before the handshake (plaintext command injection defense), and the session fully resets afterwards — the client must EHLO and re-authenticate.
  • DoS hardening: the TLS handshake is bounded by cmd_timeout on both the STARTTLS upgrade and the implicit-TLS accept, so clients that go silent mid-handshake can't hold max_connections permits indefinitely.
  • Fix: QUIT now gets exactly one 221 (previously handle_client wrote a second Bye on clean exit, causing broken-pipe error logs for fast-closing clients).
  • Removed the now-dead SmtpServer::with_tls and the smtp crate's rustls/rustls-pemfile deps; example configs and docs updated.

Testing

  • cargo test: 153 tests pass; parser tests updated for the corrected STARTTLS contract. Clippy clean on touched code.
  • Live end-to-end run against a debug build with plaintext + implicit-TLS + STARTTLS listeners (dev certs, cmd_timeout = "3s"), 10 cases, all passing:
    • plain and implicit TLS flows unchanged; STARTTLS advertise → upgrade → AUTH → send with cert verification
    • 502 on plain listeners and after upgrade; 503 mid-transaction
    • pipelined plaintext after STARTTLS is provably discarded
    • silent clients after STARTTLS (and on the implicit-TLS port) are dropped at the timeout
    • single 221 on QUIT, zero errors in server logs

The e2e checks live in a session-local script; porting them into smtp/tests/ as Rust integration tests is a possible follow-up.

Listeners with TLS config now take a mode field: "implicit" (default,
unchanged behavior) wraps the connection in TLS immediately, while
"starttls" accepts plaintext and upgrades in-session when the client
issues STARTTLS (RFC 3207), as used on submission port 587.

The previous STARTTLS path was dead code and broken in three ways:
the parser required a CRLF the read loop had already stripped, main.rs
never provided the session a TLS acceptor, and the upgrade duplicated
the raw fd (unsafe from_raw_fd), creating two owners of the socket.

- Add smtp::MaybeTlsStream, which upgrades plain->TLS in place via
  mem::replace; no unsafe, no recursive session handling. The acceptor
  travels with the stream, so each listener decides independently
  whether to offer STARTTLS.
- Advertise 250-STARTTLS per stream capability; reject with 502 when
  unavailable or already on TLS, and 503 mid-transaction.
- Discard bytes pipelined after the STARTTLS command before the
  handshake to prevent plaintext command injection, and fully reset
  the session afterwards (client must EHLO and authenticate again).
- Bound the TLS handshake with cmd_timeout on both the STARTTLS
  upgrade and the implicit-TLS accept, so silent clients cannot hold
  connection permits indefinitely.
- Fix parse of STARTTLS on CRLF-stripped lines; parameters still
  rejected.
- Send a single 221 on QUIT: handle_client no longer writes a second
  Bye on clean termination (previously caused broken-pipe errors for
  fast-closing clients, and a stray 221 after timeout's 421).
- Remove the now-unused SmtpServer::with_tls and the smtp crate's
  rustls/rustls-pemfile dependencies; update example configs and docs.

Verified with cargo test (153 tests) and a live 10-case end-to-end run
covering plain/implicit/STARTTLS listeners, injection, and timeouts.
@iamd3vil iamd3vil merged commit 487de49 into main Jul 15, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant