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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user