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
@@ -8,11 +8,18 @@ import jakarta.mail.internet.MimeMessage;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.file.Path;
import java.security.cert.X509Certificate;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
@@ -214,15 +221,131 @@ class SmtpServerTest {
assertThat(SmtpServer.path("<a b@c>")).isNull();
}
// ---------- TLS (75.1) ----------
@TempDir
Path tlsDir;
private SmtpServer tlsServer(boolean implicit) throws Exception {
server.stop();
server = new SmtpServer(new SmtpServer.Settings("127.0.0.1", 0, "mx.example.test", 2000, false,
Duration.ofSeconds(30), SmtpTls.serverContext(null, null, tlsDir, "mx.example.test"), implicit), backend);
server.start();
return server;
}
@Test
void startTlsThenAuthAndTheSessionStartsOver() throws Exception {
tlsServer(false);
try (Client client = new Client(server.port())) {
client.read();
client.send("EHLO laptop");
List<String> plain = client.readReplyLines();
assertThat(plain).contains("250-STARTTLS").noneMatch(line -> line.contains("AUTH"));
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("538");
assertThat(client.send("MAIL FROM:<x@elsewhere.test>")).startsWith("250");
assertThat(client.send("STARTTLS")).isEqualTo("220 2.0.0 Ready to start TLS");
client.startTls();
// RFC 3207: MAIL FROM before STARTTLS is forgotten, EHLO again
assertThat(client.send("MAIL FROM:<x@elsewhere.test>")).startsWith("503");
client.send("EHLO laptop");
List<String> secure = client.readReplyLines();
assertThat(secure).contains("250-AUTH PLAIN LOGIN").noneMatch(line -> line.contains("STARTTLS"));
assertThat(client.send("STARTTLS")).startsWith("503");
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("235");
client.send("MAIL FROM:<alice@example.test>");
client.send("RCPT TO:<friend@gmail.com>");
client.send("DATA");
assertThat(client.send("Subject: tls\r\n\r\nhi\r\n.")).startsWith("250");
}
assertThat(backend.messages.get(0)).contains("with ESMTPSA;");
}
// CVE-2011-0411: commands sent in plain text right after STARTTLS must not run inside the TLS session
@Test
void plainTextInjectedAfterStartTlsIsNotExecuted() throws Exception {
tlsServer(false);
try (Client client = new Client(server.port())) {
client.read();
client.send("EHLO laptop");
client.readReplyLines();
client.write("STARTTLS\r\nMAIL FROM:<x@elsewhere.test>\r\nRCPT TO:<bob@example.test>\r\n");
assertThat(client.read()).isEqualTo("220 2.0.0 Ready to start TLS");
client.startTls();
String reply = client.read();
assertThat(reply == null || reply.startsWith("421")).as(reply).isTrue();
}
assertThat(backend.envelopes).isEmpty();
}
@Test
void realClientRequiringStartTlsDelivers() throws Exception {
tlsServer(false);
Properties props = new Properties();
props.put("mail.smtp.host", "127.0.0.1");
props.put("mail.smtp.port", String.valueOf(server.port()));
props.put("mail.smtp.starttls.enable", "true");
props.put("mail.smtp.starttls.required", "true");
props.put("mail.smtp.ssl.trust", "*");
props.put("mail.smtp.ssl.checkserveridentity", "false"); // self-signed for mx.example.test, connecting to 127.0.0.1
props.put("mail.smtp.auth", "true");
Session session = Session.getInstance(props);
MimeMessage message = new MimeMessage(session);
message.setFrom(new InternetAddress("alice@example.test"));
message.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@example.test"));
message.setSubject("Over STARTTLS");
message.setText("hello");
Transport.send(message, "alice@example.test", "secret");
assertThat(backend.envelopes).hasSize(1);
assertThat(backend.envelopes.get(0).authenticatedUser()).isEqualTo("alice@example.test");
assertThat(backend.messages.get(0)).contains("with ESMTPSA;");
}
@Test
void implicitTlsPortWorksWithoutStartTls() throws Exception {
tlsServer(true);
Properties props = new Properties();
props.put("mail.smtp.host", "127.0.0.1");
props.put("mail.smtp.port", String.valueOf(server.port()));
props.put("mail.smtp.ssl.enable", "true");
props.put("mail.smtp.ssl.trust", "*");
props.put("mail.smtp.ssl.checkserveridentity", "false"); // self-signed for mx.example.test, connecting to 127.0.0.1
props.put("mail.smtp.auth", "true");
Session session = Session.getInstance(props);
MimeMessage message = new MimeMessage(session);
message.setFrom(new InternetAddress("alice@example.test"));
message.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@example.test"));
message.setSubject("Over 465");
message.setText("hello");
Transport.send(message, "alice@example.test", "secret");
assertThat(backend.messages.get(0)).contains("with ESMTPSA;");
}
@Test
void selfSignedCertificateIsCreatedOnceAndReused() throws Exception {
SmtpTls.serverContext(null, null, tlsDir, "mx.example.test");
String first = java.nio.file.Files.readString(tlsDir.resolve(SmtpTls.CERT_FILE));
SmtpTls.serverContext(null, null, tlsDir, "mx.example.test");
assertThat(java.nio.file.Files.readString(tlsDir.resolve(SmtpTls.CERT_FILE))).isEqualTo(first);
assertThat(java.nio.file.Files.readString(tlsDir.resolve(SmtpTls.KEY_FILE))).startsWith("-----BEGIN PRIVATE KEY-----");
}
private static String b64(String text) {
return Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8));
}
/** Line-oriented test client. */
static final class Client implements AutoCloseable {
private final Socket socket;
private final BufferedReader in;
private final OutputStream out;
private Socket socket;
private BufferedReader in;
private OutputStream out;
Client(int port) throws Exception {
socket = new Socket("127.0.0.1", port);
@@ -231,6 +354,28 @@ class SmtpServerTest {
out = socket.getOutputStream();
}
/** Upgrade the connection to TLS (any certificate is trusted — the test server's is self-signed). */
void startTls() throws Exception {
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, new TrustManager[]{new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String authType) {
}
public void checkServerTrusted(X509Certificate[] chain, String authType) {
}
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}}, null);
SSLSocket ssl = (SSLSocket) context.getSocketFactory().createSocket(socket, "127.0.0.1", socket.getPort(), true);
ssl.setUseClientMode(true);
ssl.startHandshake();
socket = ssl;
in = new BufferedReader(new InputStreamReader(ssl.getInputStream(), StandardCharsets.UTF_8));
out = ssl.getOutputStream();
}
void write(String text) throws Exception {
out.write(text.getBytes(StandardCharsets.UTF_8));
out.flush();