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:
@@ -3,12 +3,16 @@ package com.mailserver.config;
|
||||
import com.mailserver.delivery.SmtpDeliveryClient;
|
||||
import com.mailserver.smtp.MailRouter;
|
||||
import com.mailserver.smtp.SmtpServer;
|
||||
import com.mailserver.smtp.SmtpTls;
|
||||
import io.netty.handler.ssl.SslContext;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
|
||||
@@ -17,16 +21,46 @@ import java.util.Locale;
|
||||
@EnableScheduling
|
||||
public class MailServerConfig {
|
||||
|
||||
/**
|
||||
* Certificate for SMTP TLS: mailserver.tls.cert-file + key-file (PEM), or a self-signed one in mailserver.tls.dir.
|
||||
* mailserver.tls.enabled=false — no TLS at all.
|
||||
*/
|
||||
@Bean
|
||||
public SmtpTlsContext smtpTlsContext(@Value("${mailserver.tls.enabled:true}") boolean enabled,
|
||||
@Value("${mailserver.tls.cert-file:}") String certFile,
|
||||
@Value("${mailserver.tls.key-file:}") String keyFile,
|
||||
@Value("${mailserver.tls.dir:./tls}") String dir,
|
||||
@Value("${mailserver.smtp.hostname:localhost}") String hostname) throws Exception {
|
||||
return new SmtpTlsContext(enabled ? SmtpTls.serverContext(certFile, keyFile, Path.of(dir), hostname) : null);
|
||||
}
|
||||
|
||||
public record SmtpTlsContext(SslContext context) {
|
||||
}
|
||||
|
||||
@Bean(initMethod = "start", destroyMethod = "stop")
|
||||
@ConditionalOnProperty(name = "mailserver.smtp.enabled", havingValue = "true", matchIfMissing = true)
|
||||
public SmtpServer smtpServer(MailRouter router,
|
||||
public SmtpServer smtpServer(MailRouter router, SmtpTlsContext tls,
|
||||
@Value("${mailserver.smtp.host:0.0.0.0}") String host,
|
||||
@Value("${mailserver.smtp.port:3025}") int port,
|
||||
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
|
||||
@Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes,
|
||||
@Value("${mailserver.smtp.auth-without-tls:false}") boolean authWithoutTls,
|
||||
@Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) {
|
||||
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, authWithoutTls, idleTimeout), router);
|
||||
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, authWithoutTls, idleTimeout,
|
||||
tls.context(), false), router);
|
||||
}
|
||||
|
||||
/** Implicit TLS (RFC 8314, usually port 465) — only when mailserver.smtps.port is set and TLS is on. */
|
||||
@Bean(initMethod = "start", destroyMethod = "stop")
|
||||
@ConditionalOnExpression("${mailserver.smtps.port:0} > 0 and ${mailserver.tls.enabled:true}")
|
||||
public SmtpServer smtpsServer(MailRouter router, SmtpTlsContext tls,
|
||||
@Value("${mailserver.smtp.host:0.0.0.0}") String host,
|
||||
@Value("${mailserver.smtps.port}") int port,
|
||||
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
|
||||
@Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes,
|
||||
@Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) {
|
||||
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, false, idleTimeout,
|
||||
tls.context(), true), router);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.mailserver.smtp;
|
||||
|
||||
import io.netty.handler.ssl.SslContext;
|
||||
import io.netty.handler.ssl.SslContextBuilder;
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.GeneralName;
|
||||
import org.bouncycastle.asn1.x509.GeneralNames;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Certificate for SMTP TLS (STARTTLS and implicit TLS):
|
||||
* <ul>
|
||||
* <li>{@code cert} and {@code key} given (PEM: certificate chain and PKCS#8 key, e.g. from Let's Encrypt) — used as is;</li>
|
||||
* <li>otherwise a self-signed certificate for the server hostname, created once in {@code dir} and reused —
|
||||
* enough for encryption between our own services; other servers deliver over it too (MX delivery does not
|
||||
* check certificates, RFC 7435 opportunistic TLS).</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class SmtpTls {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SmtpTls.class);
|
||||
static final String CERT_FILE = "smtp-cert.pem";
|
||||
static final String KEY_FILE = "smtp-key.pem";
|
||||
|
||||
private SmtpTls() {
|
||||
}
|
||||
|
||||
public static SslContext serverContext(String certFile, String keyFile, Path dir, String hostname) throws Exception {
|
||||
boolean givenCert = certFile != null && !certFile.isBlank();
|
||||
boolean givenKey = keyFile != null && !keyFile.isBlank();
|
||||
if (givenCert != givenKey) {
|
||||
throw new IllegalStateException("TLS: set both the certificate and the key file, or neither");
|
||||
}
|
||||
Path cert;
|
||||
Path key;
|
||||
if (givenCert) {
|
||||
cert = Path.of(certFile);
|
||||
key = Path.of(keyFile);
|
||||
log.info("SMTP TLS: certificate {}", cert);
|
||||
} else {
|
||||
cert = dir.resolve(CERT_FILE);
|
||||
key = dir.resolve(KEY_FILE);
|
||||
if (!Files.exists(cert) || !Files.exists(key)) {
|
||||
selfSigned(hostname, cert, key);
|
||||
log.warn("SMTP TLS: created a self-signed certificate for {} in {}", hostname, dir);
|
||||
} else {
|
||||
log.info("SMTP TLS: self-signed certificate {}", cert);
|
||||
}
|
||||
}
|
||||
return SslContextBuilder.forServer(cert.toFile(), key.toFile())
|
||||
.protocols("TLSv1.3", "TLSv1.2")
|
||||
.build();
|
||||
}
|
||||
|
||||
static void selfSigned(String hostname, Path certFile, Path keyFile) throws Exception {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048, new SecureRandom());
|
||||
KeyPair pair = generator.generateKeyPair();
|
||||
X500Name name = new X500Name("CN=" + hostname);
|
||||
Instant now = Instant.now();
|
||||
JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(name,
|
||||
new BigInteger(64, new SecureRandom()), Date.from(now.minus(Duration.ofDays(1))),
|
||||
Date.from(now.plus(Duration.ofDays(3650))), name, pair.getPublic());
|
||||
builder.addExtension(Extension.subjectAlternativeName, false,
|
||||
new GeneralNames(new GeneralName(GeneralName.dNSName, hostname)));
|
||||
X509Certificate certificate = new JcaX509CertificateConverter()
|
||||
.getCertificate(builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(pair.getPrivate())));
|
||||
|
||||
Files.createDirectories(keyFile.toAbsolutePath().getParent());
|
||||
write(keyFile, pem("PRIVATE KEY", pair.getPrivate().getEncoded()), true);
|
||||
write(certFile, pem("CERTIFICATE", certificate.getEncoded()), false);
|
||||
}
|
||||
|
||||
private static String pem(String type, byte[] der) {
|
||||
return "-----BEGIN " + type + "-----\n"
|
||||
+ Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(der)
|
||||
+ "\n-----END " + type + "-----\n";
|
||||
}
|
||||
|
||||
private static void write(Path file, String text, boolean secret) throws IOException {
|
||||
Files.writeString(file, text, StandardCharsets.US_ASCII);
|
||||
if (secret) {
|
||||
try {
|
||||
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------"));
|
||||
} catch (UnsupportedOperationException ignored) {
|
||||
// not a POSIX file system
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,13 @@ mailserver.smtp.port=${SMTP_PORT:3025}
|
||||
mailserver.smtp.hostname=${MAIL_HOSTNAME:localhost}
|
||||
mailserver.smtp.auth-without-tls=${SMTP_AUTH_WITHOUT_TLS:false}
|
||||
mailserver.smtp.max-message-bytes=26214400
|
||||
# TLS (75.1): STARTTLS on the SMTP port; implicit TLS on SMTPS_PORT (465) if set. Certificate: MAIL_TLS_CERT + MAIL_TLS_KEY
|
||||
# (PEM, PKCS#8 key), otherwise a self-signed one created once in MAIL_TLS_DIR. MAIL_TLS=false — no TLS
|
||||
mailserver.tls.enabled=${MAIL_TLS:true}
|
||||
mailserver.tls.cert-file=${MAIL_TLS_CERT:}
|
||||
mailserver.tls.key-file=${MAIL_TLS_KEY:}
|
||||
mailserver.tls.dir=${MAIL_TLS_DIR:./tls}
|
||||
mailserver.smtps.port=${SMTPS_PORT:0}
|
||||
|
||||
# Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend)
|
||||
mailserver.outbound.mode=${OUTBOUND_MODE:off}
|
||||
|
||||
@@ -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