75.1 TLS для SMTP: STARTTLS и неявный TLS (465)

- SmtpServer: STARTTLS (RFC 3207) — SslHandler ставится сразу после того,
  как ответ 220 ушёл открытым текстом; сессия начинается заново; строки,
  присланные открытым текстом вместе со STARTTLS, не выполняются, соединение
  закрывается (CVE-2011-0411); свежий декодер строк без остатков открытого текста
- неявный TLS на SMTPS_PORT (RFC 8314); AUTH — только внутри TLS
  (или SMTP_AUTH_WITHOUT_TLS); Received: ESMTPS/ESMTPSA
- SmtpTls: свой сертификат (MAIL_TLS_CERT/KEY) или самоподписанный,
  создаётся один раз в MAIL_TLS_DIR (в образе /app/data/tls); TLS 1.2/1.3
- тесты: STARTTLS + AUTH, подмешанный открытый текст, JavaMail со
  starttls.required, порт 465, переиспользование сертификата (38 всего)
- проверено вживую: openssl s_client -starttls smtp и порт 465, TLSv1.3

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 19:42:10 +01:00
parent 37f6b3ef3d
commit 17d489e400
7 changed files with 431 additions and 24 deletions
@@ -1,13 +1,22 @@
package com.mailserver.smtp;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufUtil;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPipeline;
import io.netty.handler.codec.LineBasedFrameDecoder;
import io.netty.handler.codec.MessageToMessageDecoder;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.timeout.ReadTimeoutHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import reactor.netty.Connection;
import reactor.netty.DisposableServer;
import reactor.netty.NettyOutbound;
import reactor.netty.tcp.TcpServer;
import java.io.ByteArrayOutputStream;
@@ -32,7 +41,10 @@ import java.util.concurrent.TimeUnit;
* <li>the message is stored or queued before the client gets 250 — on failure 451 and the client retries;</li>
* <li>database and DNS work runs on a worker thread, not on the event loop.</li>
* </ul>
* No STARTTLS yet: AUTH is offered only when {@code authWithoutTls} is on (a closed network: inside a VM or a docker network).
* TLS (RFC 3207 STARTTLS, or implicit TLS on a separate port, RFC 8314): with a {@code tls} context STARTTLS is
* offered; AUTH only over TLS, or without it when {@code authWithoutTls} is on (a closed network: VM, docker network).
* After STARTTLS the session starts over (new EHLO), and lines sent in plain text together with STARTTLS are not
* executed — the connection is closed (command injection, CVE-2011-0411).
*/
public class SmtpServer {
@@ -42,9 +54,32 @@ public class SmtpServer {
static final int MAX_LINE = 64 * 1024;
static final int MAX_RECIPIENTS = 100;
private static final int MAX_BAD_COMMANDS = 20;
// Compared by identity: TLS starts right after exactly this reply is written
private static final String STARTTLS_READY = new String("220 2.0.0 Ready to start TLS\r\n");
/**
* @param tls certificate for STARTTLS / implicit TLS; null — no TLS
* @param implicitTls TLS from the first byte (port 465), no STARTTLS
*/
public record Settings(String host, int port, String hostname, long maxMessageBytes, boolean authWithoutTls,
Duration idleTimeout) {
Duration idleTimeout, SslContext tls, boolean implicitTls) {
public Settings(String host, int port, String hostname, long maxMessageBytes, boolean authWithoutTls,
Duration idleTimeout) {
this(host, port, hostname, maxMessageBytes, authWithoutTls, idleTimeout, null, false);
}
}
/** A line of input and whether it came over TLS. */
record Line(byte[] bytes, boolean tls) {
}
/** Marks each line: decoded behind an SslHandler (encrypted) or not. */
private static final class LineTagger extends MessageToMessageDecoder<ByteBuf> {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf frame, List<Object> out) {
out.add(new Line(ByteBufUtil.getBytes(frame), ctx.pipeline().get(SslHandler.class) != null));
}
}
private final Settings settings;
@@ -57,32 +92,70 @@ public class SmtpServer {
}
public void start() {
server = TcpServer.create()
if (settings.implicitTls() && settings.tls() == null) {
throw new IllegalStateException("Implicit TLS needs a certificate");
}
TcpServer tcp = TcpServer.create()
.host(settings.host())
.port(settings.port())
.doOnConnection(connection -> connection
.addHandlerFirst("smtp-idle", new ReadTimeoutHandler(settings.idleTimeout().toSeconds(), TimeUnit.SECONDS))
.addHandlerLast("smtp-lines", new LineBasedFrameDecoder(MAX_LINE, true, false)))
.addHandlerLast("smtp-lines", new LineBasedFrameDecoder(MAX_LINE, true, false))
.addHandlerLast("smtp-tag", new LineTagger()));
if (settings.implicitTls()) {
tcp = tcp.secure(spec -> spec.sslContext(settings.tls()));
}
server = tcp
.handle((inbound, outbound) -> {
Connection[] connection = {null};
String[] remoteIp = {"unknown"};
inbound.withConnection(c -> {
connection[0] = c;
if (c.address() instanceof InetSocketAddress address && address.getAddress() != null) {
remoteIp[0] = address.getAddress().getHostAddress();
}
});
Session session = new Session(remoteIp[0]);
Flux<String> replies = inbound.receive().asByteArray()
Session session = new Session(remoteIp[0], settings.implicitTls());
Flux<String> replies = inbound.receiveObject().cast(Line.class)
.concatMap(session::onLine)
.onErrorResume(e -> {
log.debug("SMTP {}: connection closed: {}", session.remoteIp, e.toString());
return Mono.just("421 4.4.2 " + settings.hostname() + " closing connection\r\n");
})
.takeUntil(reply -> session.closing);
return outbound.sendString(Flux.concat(
Mono.just("220 " + settings.hostname() + " ESMTP ready\r\n"), replies), StandardCharsets.US_ASCII);
// One reply at a time, each written before the next line is handled: after "220 Ready to start TLS"
// the TLS handler goes in only when the reply has left in plain text
return Flux.concat(Mono.just("220 " + settings.hostname() + " ESMTP ready\r\n"), replies)
.concatMap(reply -> send(outbound, reply)
.then(Mono.defer(() -> reply == STARTTLS_READY ? startTls(connection[0], session) : Mono.<Void>empty())))
.then();
})
.bindNow(Duration.ofSeconds(10));
log.info("SMTP server listening on {}:{} ({})", settings.host(), port(), settings.hostname());
log.info("SMTP server listening on {}:{} ({}{})", settings.host(), port(), settings.hostname(),
settings.implicitTls() ? ", implicit TLS" : settings.tls() != null ? ", STARTTLS" : ", no TLS");
}
private static Mono<Void> send(NettyOutbound outbound, String reply) {
return outbound.sendString(Mono.just(reply), StandardCharsets.US_ASCII).then();
}
/** TLS handler in front, fresh line decoder (no plain-text leftovers mixed into encrypted input). */
private Mono<Void> startTls(Connection connection, Session session) {
ChannelPipeline pipeline = connection.channel().pipeline();
SslHandler ssl = settings.tls().newHandler(connection.channel().alloc());
pipeline.replace("smtp-lines", "smtp-lines", new LineBasedFrameDecoder(MAX_LINE, true, false));
pipeline.addFirst("smtp-tls", ssl);
return Mono.create(sink -> ssl.handshakeFuture().addListener(future -> {
if (future.isSuccess()) {
log.debug("SMTP {}: TLS {}", session.remoteIp, ssl.engine().getSession().getProtocol());
sink.success();
} else {
log.info("SMTP {}: TLS handshake failed: {}", session.remoteIp, future.cause().toString());
session.closing = true;
connection.dispose();
sink.success();
}
}));
}
public int port() {
@@ -99,6 +172,7 @@ public class SmtpServer {
private final class Session {
final String remoteIp;
boolean tls;
State state = State.COMMAND;
String helo;
boolean extended;
@@ -111,12 +185,17 @@ public class SmtpServer {
int badCommands;
volatile boolean closing;
Session(String remoteIp) {
Session(String remoteIp, boolean tls) {
this.remoteIp = remoteIp;
this.tls = tls;
}
Mono<String> onLine(byte[] raw) {
byte[] line = stripCr(raw);
Mono<String> onLine(Line input) {
if (tls && !input.tls()) {
// Sent in plain text after STARTTLS, before the handshake: never execute it
return Mono.error(new IllegalStateException("plain-text input after STARTTLS"));
}
byte[] line = stripCr(input.bytes());
return switch (state) {
case DATA -> onDataLine(line);
case AUTH_PLAIN -> authPlain(ascii(line));
@@ -142,7 +221,7 @@ public class SmtpServer {
}
case "NOOP" -> reply("250 2.0.0 OK");
case "VRFY" -> reply("252 2.5.0 Cannot VRFY user, but will accept message");
case "STARTTLS" -> reply("454 4.7.0 TLS not available");
case "STARTTLS" -> startTls(arg);
case "QUIT" -> {
closing = true;
yield reply("221 2.0.0 " + settings.hostname() + " Bye");
@@ -167,15 +246,41 @@ public class SmtpServer {
sb.append("250-SIZE ").append(settings.maxMessageBytes()).append("\r\n");
sb.append("250-8BITMIME\r\n");
sb.append("250-PIPELINING\r\n");
if (settings.authWithoutTls()) {
if (settings.tls() != null && !tls) {
sb.append("250-STARTTLS\r\n");
}
if (authAllowed()) {
sb.append("250-AUTH PLAIN LOGIN\r\n");
}
sb.append("250 ENHANCEDSTATUSCODES\r\n");
return Mono.just(sb.toString());
}
private boolean authAllowed() {
return tls || settings.authWithoutTls();
}
private Mono<String> startTls(String arg) {
if (settings.tls() == null) {
return reply("454 4.7.0 TLS not available");
}
if (tls) {
return bad("503 5.5.1 TLS already active");
}
if (!arg.isEmpty()) {
return reply("501 5.5.4 Syntax: STARTTLS");
}
// RFC 3207 4.2: everything known about the client is forgotten, a new EHLO is expected
tls = true;
helo = null;
extended = false;
authenticatedUser = null;
resetTransaction();
return Mono.just(STARTTLS_READY);
}
private Mono<String> auth(String arg) {
if (!settings.authWithoutTls()) {
if (!authAllowed()) {
return reply("538 5.7.11 Encryption required for requested authentication mechanism");
}
if (helo == null || !extended) {
@@ -369,7 +474,7 @@ public class SmtpServer {
private byte[] withReceivedHeader(byte[] message) {
String received = "Received: from " + helo + " ([" + remoteIp + "])\r\n"
+ "\tby " + settings.hostname() + " with " + (authenticatedUser != null ? "ESMTPA" : "ESMTP") + ";\r\n"
+ "\tby " + settings.hostname() + " with " + (tls ? "ESMTPS" : "ESMTP") + (authenticatedUser != null ? "A" : "") + ";\r\n"
+ "\t" + DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()) + "\r\n";
byte[] header = received.getBytes(StandardCharsets.US_ASCII);
byte[] all = new byte[header.length + message.length];