continuous-integration/drone/pr Build is passing
Один LoginGuard на все проверки пароля (SMTP AUTH, IMAP LOGIN/AUTHENTICATE, вход в веб, смена пароля): 10 неверных с IP за 15 мин → блок 15 мин, дальше вдвое дольше до суток; 20 неверных на ящик с разных IP → ящик закрыт, кроме IP, с которых в него уже входили. Во время блока пароль не проверяется (SMTP 421, IMAP BYE + NO [UNAVAILABLE], веб 429 + Retry-After); неверный пароль — ответ через 1 с; 3 неверных в соединении — обрыв. X-Forwarded-For веб-входа — только от прокси в частной сети, иначе заголовок отбрасывается (подменой IP лимит не обойти). Попутно: IMAP терял тегированный ответ после "* BYE" (LOGOUT, обрыв после неудачных входов) — takeUntil теперь после тегированной строки. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
374 lines
16 KiB
Java
374 lines
16 KiB
Java
package com.mailserver.imap;
|
|
|
|
import com.mailserver.smtp.SmtpTls;
|
|
import jakarta.mail.BodyPart;
|
|
import jakarta.mail.Flags;
|
|
import jakarta.mail.Folder;
|
|
import jakarta.mail.Message;
|
|
import jakarta.mail.Multipart;
|
|
import jakarta.mail.Session;
|
|
import jakarta.mail.Store;
|
|
import jakarta.mail.UIDFolder;
|
|
import jakarta.mail.internet.InternetAddress;
|
|
import jakarta.mail.internet.MimeMessage;
|
|
import jakarta.mail.search.SubjectTerm;
|
|
import org.eclipse.angus.mail.imap.AppendUID;
|
|
import org.eclipse.angus.mail.imap.IMAPFolder;
|
|
import com.mailserver.security.LoginGuard;
|
|
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 javax.net.ssl.SSLContext;
|
|
import javax.net.ssl.SSLSocket;
|
|
import javax.net.ssl.TrustManager;
|
|
import javax.net.ssl.X509TrustManager;
|
|
import java.io.BufferedReader;
|
|
import java.io.InputStreamReader;
|
|
import java.io.OutputStream;
|
|
import java.net.Socket;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.file.Path;
|
|
import java.security.cert.X509Certificate;
|
|
import java.time.Duration;
|
|
import java.util.ArrayList;
|
|
import java.util.Arrays;
|
|
import java.util.List;
|
|
import java.util.Properties;
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
|
|
class ImapServerTest {
|
|
|
|
static final String PLAIN = """
|
|
From: Bob <bob@elsewhere.test>
|
|
To: alice@example.test
|
|
Subject: Hello Alice
|
|
Date: Thu, 05 Nov 2026 10:00:00 +0000
|
|
Message-ID: <hello@elsewhere.test>
|
|
Content-Type: text/plain; charset=utf-8
|
|
|
|
Hi there, line one.
|
|
Line two.
|
|
""";
|
|
|
|
static final String WITH_FILE = """
|
|
From: =?UTF-8?B?0JDQvdC90LA=?= <anna@elsewhere.test>
|
|
To: alice@example.test
|
|
Subject: =?UTF-8?B?0J7RgtGH0ZHRgg==?=
|
|
Date: Thu, 05 Nov 2026 11:00:00 +0000
|
|
MIME-Version: 1.0
|
|
Content-Type: multipart/mixed; boundary="b1"
|
|
|
|
preamble
|
|
--b1
|
|
Content-Type: text/plain; charset=utf-8
|
|
|
|
See the report.
|
|
--b1
|
|
Content-Type: application/pdf; name="report.pdf"
|
|
Content-Disposition: attachment; filename="report.pdf"
|
|
Content-Transfer-Encoding: base64
|
|
|
|
JVBERi0xLjQgdGVzdA==
|
|
--b1--
|
|
""";
|
|
|
|
@TempDir
|
|
Path tlsDir;
|
|
|
|
private MemoryImapStore store;
|
|
private ImapServer server;
|
|
|
|
@BeforeEach
|
|
void start() throws Exception {
|
|
store = new MemoryImapStore();
|
|
store.deliver("INBOX", PLAIN);
|
|
store.deliver("INBOX", WITH_FILE);
|
|
server = new ImapServer(new ImapServer.Settings("127.0.0.1", 0, "mx.example.test", false,
|
|
SmtpTls.serverContext(null, null, tlsDir, "mx.example.test"), false, 1_000_000,
|
|
Duration.ofMinutes(5), Duration.ofMillis(200)), store);
|
|
server.start();
|
|
}
|
|
|
|
@AfterEach
|
|
void stop() {
|
|
server.stop();
|
|
}
|
|
|
|
private Store connect() throws Exception {
|
|
Properties props = new Properties();
|
|
props.put("mail.imap.host", "127.0.0.1");
|
|
props.put("mail.imap.port", String.valueOf(server.port()));
|
|
props.put("mail.imap.starttls.enable", "true");
|
|
props.put("mail.imap.starttls.required", "true");
|
|
props.put("mail.imap.ssl.trust", "*");
|
|
props.put("mail.imap.ssl.checkserveridentity", "false");
|
|
Store imap = Session.getInstance(props).getStore("imap");
|
|
imap.connect("alice@example.test", "secret");
|
|
return imap;
|
|
}
|
|
|
|
@Test
|
|
void realClientReadsFoldersMessagesAndAttachments() throws Exception {
|
|
try (Store imap = connect()) {
|
|
List<String> names = Arrays.stream(imap.getDefaultFolder().list("*")).map(Folder::getFullName).toList();
|
|
assertThat(names).contains("INBOX", "Drafts", "Sent", "Spam", "Trash");
|
|
|
|
IMAPFolder inbox = (IMAPFolder) imap.getFolder("INBOX");
|
|
inbox.open(Folder.READ_WRITE);
|
|
assertThat(inbox.getMessageCount()).isEqualTo(2);
|
|
assertThat(inbox.getUnreadMessageCount()).isEqualTo(2);
|
|
|
|
Message first = inbox.getMessage(1);
|
|
assertThat(first.getSubject()).isEqualTo("Hello Alice");
|
|
assertThat(((InternetAddress) first.getFrom()[0]).getPersonal()).isEqualTo("Bob");
|
|
assertThat(first.getContent().toString()).contains("Line two.");
|
|
assertThat(first.isSet(Flags.Flag.SEEN)).isTrue(); // BODY[] without PEEK
|
|
|
|
Message second = inbox.getMessage(2);
|
|
assertThat(second.getSubject()).isEqualTo("Отчёт");
|
|
assertThat(((InternetAddress) second.getFrom()[0]).getPersonal()).isEqualTo("Анна");
|
|
Multipart parts = (Multipart) second.getContent(); // BODYSTRUCTURE, then BODY[1] and BODY[2]
|
|
assertThat(parts.getCount()).isEqualTo(2);
|
|
assertThat(parts.getBodyPart(0).getContent().toString()).contains("See the report.");
|
|
BodyPart file = parts.getBodyPart(1);
|
|
assertThat(file.getFileName()).isEqualTo("report.pdf");
|
|
assertThat(new String(file.getInputStream().readAllBytes(), StandardCharsets.US_ASCII)).isEqualTo("%PDF-1.4 test");
|
|
|
|
assertThat(inbox.search(new SubjectTerm("hello"))).hasSize(1);
|
|
assertThat(inbox.getUID(first)).isEqualTo(1);
|
|
assertThat(inbox.getMessageByUID(2).getSubject()).isEqualTo("Отчёт");
|
|
}
|
|
}
|
|
|
|
@Test
|
|
void appendCopyMoveAndExpunge() throws Exception {
|
|
try (Store imap = connect()) {
|
|
IMAPFolder sent = (IMAPFolder) imap.getFolder("Sent");
|
|
MimeMessage outgoing = new MimeMessage(Session.getInstance(new Properties()));
|
|
outgoing.setFrom(new InternetAddress("alice@example.test"));
|
|
outgoing.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@elsewhere.test"));
|
|
outgoing.setSubject("Sent from Thunderbird");
|
|
outgoing.setText("body");
|
|
AppendUID[] appended = sent.appendUIDMessages(new Message[]{outgoing});
|
|
assertThat(appended[0].uid).isEqualTo(1);
|
|
assertThat(appended[0].uidvalidity).isEqualTo(store.box("Sent").uidValidity());
|
|
|
|
IMAPFolder inbox = (IMAPFolder) imap.getFolder("INBOX");
|
|
inbox.open(Folder.READ_WRITE);
|
|
inbox.copyMessages(new Message[]{inbox.getMessage(1)}, imap.getFolder("Trash"));
|
|
inbox.moveMessages(new Message[]{inbox.getMessage(2)}, imap.getFolder("Spam"));
|
|
inbox.close(false); // JavaMail counts EXPUNGEd messages until the folder is opened again
|
|
inbox.open(Folder.READ_WRITE);
|
|
assertThat(inbox.getMessageCount()).isEqualTo(1);
|
|
|
|
inbox.getMessage(1).setFlag(Flags.Flag.DELETED, true);
|
|
inbox.expunge();
|
|
assertThat(inbox.getMessageCount()).isZero();
|
|
}
|
|
assertThat(store.entries(store.box("Trash").id())).hasSize(1);
|
|
assertThat(store.entries(store.box("Spam").id())).hasSize(1);
|
|
assertThat(store.entries(store.box("Sent").id())).hasSize(1);
|
|
}
|
|
|
|
@Test
|
|
void createRenameAndDeleteFolders() throws Exception {
|
|
try (Store imap = connect()) {
|
|
Folder projects = imap.getFolder("Проекты");
|
|
assertThat(projects.create(Folder.HOLDS_MESSAGES)).isTrue();
|
|
assertThat(Arrays.stream(imap.getDefaultFolder().list("*")).map(Folder::getFullName)).contains("Проекты");
|
|
assertThat(projects.renameTo(imap.getFolder("Archive"))).isTrue();
|
|
assertThat(imap.getFolder("Archive").delete(false)).isTrue();
|
|
assertThat(imap.getFolder("Archive").exists()).isFalse();
|
|
}
|
|
}
|
|
|
|
@Test
|
|
void wrongPasswordsCloseTheConnectionAndTheGuardBlocksTheIp() throws Exception {
|
|
server.stop();
|
|
LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 4, 100, Duration.ofMinutes(15),
|
|
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), java.util.Set.of(), Duration.ZERO));
|
|
server = new ImapServer(new ImapServer.Settings("127.0.0.1", 0, "mx.example.test", false,
|
|
SmtpTls.serverContext(null, null, tlsDir, "mx.example.test"), false, 1_000_000,
|
|
Duration.ofMinutes(5), Duration.ofMillis(200)), store, guard);
|
|
server.start();
|
|
try (Client client = new Client(server.port())) {
|
|
client.read();
|
|
client.tls("s");
|
|
assertThat(client.send("a LOGIN alice@example.test w1")).startsWith("a NO [AUTHENTICATIONFAILED]");
|
|
assertThat(client.send("b LOGIN alice@example.test w2")).startsWith("b NO [AUTHENTICATIONFAILED]");
|
|
assertThat(client.untilTagged("c", "c LOGIN alice@example.test w3")).contains("* BYE Too many failed logins");
|
|
assertThat(client.read()).isNull();
|
|
}
|
|
try (Client client = new Client(server.port())) {
|
|
client.read();
|
|
client.tls("s");
|
|
assertThat(client.send("a LOGIN alice@example.test w4")).startsWith("a NO [AUTHENTICATIONFAILED]");
|
|
assertThat(client.untilTagged("b", "b LOGIN alice@example.test secret"))
|
|
.anyMatch(line -> line.startsWith("* BYE Too many failed logins, try again in 15 min"))
|
|
.anyMatch(line -> line.startsWith("b NO [UNAVAILABLE]"));
|
|
}
|
|
}
|
|
|
|
@Test
|
|
void loginIsRefusedInPlainTextAndTheGreetingSaysSo() throws Exception {
|
|
try (Client client = new Client(server.port())) {
|
|
String greeting = client.read();
|
|
assertThat(greeting).contains("STARTTLS").contains("LOGINDISABLED").doesNotContain("AUTH=PLAIN");
|
|
assertThat(client.send("a LOGIN alice@example.test secret")).startsWith("a NO [PRIVACYREQUIRED]");
|
|
}
|
|
}
|
|
|
|
@Test
|
|
void literalsAndLiteralPlus() throws Exception {
|
|
try (Client client = new Client(server.port())) {
|
|
client.read();
|
|
client.tls("s");
|
|
client.write("a LOGIN {18}\r\n");
|
|
assertThat(client.read()).startsWith("+ ");
|
|
client.write("alice@example.test {6+}\r\nsecret\r\n");
|
|
assertThat(client.read()).startsWith("a OK");
|
|
String message = "Subject: via literal+\r\n\r\nhi\r\n";
|
|
client.write("b APPEND Drafts (\\Seen) {" + message.length() + "+}\r\n" + message + "\r\n");
|
|
assertThat(client.read()).startsWith("b OK [APPENDUID");
|
|
}
|
|
assertThat(store.entries(store.box("Drafts").id())).hasSize(1);
|
|
}
|
|
|
|
@Test
|
|
void idleReportsNewMailUntilDone() throws Exception {
|
|
try (Client client = new Client(server.port())) {
|
|
client.read();
|
|
client.tls("s");
|
|
client.send("a LOGIN alice@example.test secret");
|
|
client.untilTagged("b", "b SELECT INBOX");
|
|
client.write("c IDLE\r\n");
|
|
assertThat(client.read()).startsWith("+ idling");
|
|
store.deliver("INBOX", PLAIN);
|
|
assertThat(client.read()).isEqualTo("* 3 EXISTS");
|
|
client.write("DONE\r\n");
|
|
assertThat(client.read()).startsWith("c OK");
|
|
assertThat(client.send("d NOOP")).startsWith("d OK");
|
|
}
|
|
}
|
|
|
|
// CVE-2011-0411 class: commands sent in plain text right after STARTTLS must not run inside TLS
|
|
@Test
|
|
void plainTextAfterStartTlsIsNotExecuted() throws Exception {
|
|
try (Client client = new Client(server.port())) {
|
|
client.read();
|
|
client.write("s STARTTLS\r\nx LOGIN alice@example.test secret\r\n");
|
|
assertThat(client.read()).startsWith("s OK");
|
|
String reply;
|
|
try {
|
|
client.startTls();
|
|
reply = client.read();
|
|
} catch (java.io.IOException closed) {
|
|
reply = null; // the server closed the connection — the expected outcome
|
|
}
|
|
assertThat(reply).isNull();
|
|
}
|
|
}
|
|
|
|
@Test
|
|
void sectionsOfANestedMessage() {
|
|
String raw = ("Subject: outer\r\nContent-Type: multipart/mixed; boundary=x\r\n\r\n"
|
|
+ "--x\r\nContent-Type: text/plain\r\n\r\nouter text\r\n"
|
|
+ "--x\r\nContent-Type: message/rfc822\r\n\r\nSubject: inner\r\nContent-Type: text/plain\r\n\r\ninner text\r\n"
|
|
+ "--x--\r\n");
|
|
MimeTree tree = MimeTree.parse(raw.getBytes(StandardCharsets.US_ASCII));
|
|
assertThat(text(ImapSession.section(tree, "1"))).isEqualTo("outer text");
|
|
assertThat(text(ImapSession.section(tree, "2.HEADER"))).isEqualTo("Subject: inner\r\nContent-Type: text/plain\r\n\r\n");
|
|
assertThat(text(ImapSession.section(tree, "2.TEXT"))).isEqualTo("inner text");
|
|
assertThat(text(ImapSession.section(tree, "2.1"))).isEqualTo("inner text");
|
|
assertThat(text(ImapSession.section(tree, "1.MIME"))).isEqualTo("Content-Type: text/plain\r\n\r\n");
|
|
assertThat(text(ImapSession.section(tree, "HEADER.FIELDS (SUBJECT)"))).isEqualTo("Subject: outer\r\n\r\n");
|
|
assertThat(text(ImapSession.section(tree, ""))).isEqualTo(raw);
|
|
}
|
|
|
|
@Test
|
|
void mailboxNamesUseModifiedUtf7() {
|
|
// the example of RFC 3501 5.1.3
|
|
assertThat(ImapWriter.encodeMailbox("~peter/mail/台北/日本語")).isEqualTo("~peter/mail/&U,BTFw-/&ZeVnLIqe-");
|
|
assertThat(ImapWriter.decodeMailbox("~peter/mail/&U,BTFw-/&ZeVnLIqe-")).isEqualTo("~peter/mail/台北/日本語");
|
|
assertThat(ImapWriter.decodeMailbox(ImapWriter.encodeMailbox("Проекты & план"))).isEqualTo("Проекты & план");
|
|
}
|
|
|
|
private static String text(byte[] bytes) {
|
|
return new String(bytes, StandardCharsets.UTF_8);
|
|
}
|
|
|
|
/** Line-oriented IMAP test client. */
|
|
static final class Client implements AutoCloseable {
|
|
private Socket socket;
|
|
private BufferedReader in;
|
|
private OutputStream out;
|
|
|
|
Client(int port) throws Exception {
|
|
socket = new Socket("127.0.0.1", port);
|
|
socket.setSoTimeout(5000);
|
|
in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
|
|
out = socket.getOutputStream();
|
|
}
|
|
|
|
void write(String text) throws Exception {
|
|
out.write(text.getBytes(StandardCharsets.UTF_8));
|
|
out.flush();
|
|
}
|
|
|
|
String read() throws Exception {
|
|
return in.readLine();
|
|
}
|
|
|
|
String send(String line) throws Exception {
|
|
write(line + "\r\n");
|
|
return read();
|
|
}
|
|
|
|
/** Sends a command, returns all lines up to its tagged response. */
|
|
List<String> untilTagged(String tag, String line) throws Exception {
|
|
write(line + "\r\n");
|
|
List<String> lines = new ArrayList<>();
|
|
String next;
|
|
do {
|
|
next = read();
|
|
lines.add(next);
|
|
} while (next != null && !next.startsWith(tag + " "));
|
|
return lines;
|
|
}
|
|
|
|
void tls(String tag) throws Exception {
|
|
assertThat(send(tag + " STARTTLS")).startsWith(tag + " OK");
|
|
startTls();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
@Override
|
|
public void close() throws Exception {
|
|
socket.close();
|
|
}
|
|
}
|
|
}
|