From b19e9682a8a82ba75175eb4d0e8158fd5f5505a4 Mon Sep 17 00:00:00 2001 From: Liosha84 Date: Fri, 6 Nov 2026 18:32:33 +0100 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D1=89=D0=B8=D1=82=D0=B0=20=D0=BE?= =?UTF-8?q?=D1=82=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B1=D0=BE=D1=80=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D0=B5=D0=B9:=20SMTP,=20IMAP,=20?= =?UTF-8?q?=D0=B2=D0=B5=D0=B1=20=E2=80=94=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82?= =?UTF-8?q?=20=D0=BF=D0=BE=20IP=20=D0=B8=20=D0=BF=D0=BE=20=D1=8F=D1=89?= =?UTF-8?q?=D0=B8=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Один 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 --- README.md | 19 +- .../com/mailserver/config/ImapConfig.java | 9 +- .../mailserver/config/LoginGuardConfig.java | 74 +++++ .../mailserver/config/MailServerConfig.java | 9 +- .../controller/AuthenticationController.java | 84 +++++- .../java/com/mailserver/imap/ImapServer.java | 17 +- .../java/com/mailserver/imap/ImapSession.java | 47 +++- .../com/mailserver/security/LoginGuard.java | 257 ++++++++++++++++++ .../java/com/mailserver/smtp/SmtpServer.java | 32 ++- .../src/main/resources/application.properties | 12 + .../config/LoginGuardConfigTest.java | 46 ++++ .../AuthenticationControllerTest.java | 81 ++++++ .../com/mailserver/imap/ImapServerTest.java | 28 ++ .../mailserver/security/LoginGuardTest.java | 164 +++++++++++ .../com/mailserver/smtp/SmtpServerTest.java | 29 ++ 15 files changed, 876 insertions(+), 32 deletions(-) create mode 100644 backend/src/main/java/com/mailserver/config/LoginGuardConfig.java create mode 100644 backend/src/main/java/com/mailserver/security/LoginGuard.java create mode 100644 backend/src/test/java/com/mailserver/config/LoginGuardConfigTest.java create mode 100644 backend/src/test/java/com/mailserver/controller/AuthenticationControllerTest.java create mode 100644 backend/src/test/java/com/mailserver/security/LoginGuardTest.java diff --git a/README.md b/README.md index 3436c47..716a207 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,22 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api | `DKIM_DOMAIN`, `DKIM_SELECTOR`, `DKIM_KEYS_DIR` | DKIM | `localhost`, `mail`, `./dkim-keys` | | `SPF_ENABLED`, `DMARC_ENABLED` | проверки входящих | `true` | | `MAIL_CORS_ORIGINS` | другие источники для веб-клиента (только разработка) | — | +| `LOGIN_GUARD` | защита от перебора паролей (см. ниже) | `true` | +| `LOGIN_GUARD_MAX_PER_IP`, `LOGIN_GUARD_MAX_PER_ACCOUNT` | неверных паролей за окно до блокировки: с одного IP; на один ящик с любых IP | `10`, `20` | +| `LOGIN_GUARD_WINDOW`, `LOGIN_GUARD_BLOCK`, `LOGIN_GUARD_MAX_BLOCK` | окно подсчёта; первая блокировка; потолок (каждая следующая блокировка того же IP вдвое дольше) | `15m`, `15m`, `24h` | +| `LOGIN_GUARD_TRUSTED_IPS` | IP через запятую, которые не блокируются по IP (ящик всё равно считается) | — | + +### Защита от перебора паролей + +Один счётчик на всё, где проверяется пароль: `AUTH` в SMTP (25/587/465), `LOGIN`/`AUTHENTICATE` в IMAP, вход в веб-почту, смена пароля. + +- **По IP:** 10 неверных паролей за 15 минут → IP заблокирован на 15 минут, следующая блокировка — 30 минут, потом час… до суток. Сутки тишины — счёт сначала. +- **По ящику:** 20 неверных паролей на один ящик с разных IP (распределённый перебор) → ящик закрыт на 15 минут, **кроме** IP, с которых в него уже входили успешно (30 дней) — так перебором нельзя запереть владельца из его же почтовой программы. +- Пока блокировка — пароль **не проверяется** вовсе: SMTP `421 4.7.0` и обрыв, IMAP `* BYE` + `NO [UNAVAILABLE]`, веб `429` с `Retry-After`. Неверный пароль отвечается через секунду; после 3 неверных в одном соединении SMTP и IMAP его закрывают. +- «Нет такого ящика» и «неверный пароль» снаружи не отличить, оба считаются. +- В логе: `login guard: IP … blocked for 15 min after 10 failed logins (block #1)`. +- `X-Forwarded-For` веб-входа принимается только от прокси в частной сети (Caddy Jambotron приходит через docker/WireGuard); с публичного адреса заголовок отбрасывается — подменой IP лимит не обойти. +- Счётчики в памяти: перезапуск их сбрасывает. ## Разработка @@ -164,7 +180,8 @@ DNS до первого запуска: - [ ] IMAP: проверить с Thunderbird на настоящем адресе; вложенные папки, CONDSTORE/QRESYNC, поиск по `BODY`/`TEXT` без перебора писем. - [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново. - [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма. -- [ ] Защита регистрации и входа от перебора (лимит попыток, капча). +- [x] Защита входа от перебора: SMTP, IMAP, веб (лимит по IP и по ящику, см. «Защита от перебора паролей»). +- [ ] Защита регистрации от массового заведения ящиков (лимит, капча) — пока регистрация на публичной почте закрыта. - [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя). - [ ] Настоящая почта (75.6): `docker-compose.public.yml` готов, первый запуск — `mail.jambocheck.com` на публичном адресе Horizon; адрес «прогревать» несколько недель, письма пользователям Jambotron пока через Resend (75.5). diff --git a/backend/src/main/java/com/mailserver/config/ImapConfig.java b/backend/src/main/java/com/mailserver/config/ImapConfig.java index bc78abe..38f4664 100644 --- a/backend/src/main/java/com/mailserver/config/ImapConfig.java +++ b/backend/src/main/java/com/mailserver/config/ImapConfig.java @@ -1,5 +1,6 @@ package com.mailserver.config; +import com.mailserver.security.LoginGuard; import com.mailserver.imap.DbImapStore; import com.mailserver.imap.ImapServer; import com.mailserver.imap.ImapStore; @@ -32,7 +33,7 @@ public class ImapConfig { @Bean(initMethod = "start", destroyMethod = "stop") @ConditionalOnProperty(name = "mailserver.imap.enabled", havingValue = "true", matchIfMissing = true) - public ImapServer imapServer(ImapStore store, MailServerConfig.SmtpTlsContext tls, + public ImapServer imapServer(ImapStore store, MailServerConfig.SmtpTlsContext tls, LoginGuard guard, @Value("${mailserver.imap.host:0.0.0.0}") String host, @Value("${mailserver.imap.port:3143}") int port, @Value("${mailserver.smtp.hostname:localhost}") String hostname, @@ -41,12 +42,12 @@ public class ImapConfig { @Value("${mailserver.imap.idle-timeout:30m}") Duration idleTimeout, @Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) { return new ImapServer(new ImapServer.Settings(host, port, hostname, authWithoutTls, tls.context(), false, - (int) maxLiteral.toBytes(), idleTimeout, idlePoll), store); + (int) maxLiteral.toBytes(), idleTimeout, idlePoll), store, guard); } @Bean(initMethod = "start", destroyMethod = "stop") @ConditionalOnExpression("'${mailserver.imap.enabled:true}' == 'true' and ${mailserver.imaps.port:0} > 0 and ${mailserver.tls.enabled:true}") - public ImapServer imapsServer(ImapStore store, MailServerConfig.SmtpTlsContext tls, + public ImapServer imapsServer(ImapStore store, MailServerConfig.SmtpTlsContext tls, LoginGuard guard, @Value("${mailserver.imap.host:0.0.0.0}") String host, @Value("${mailserver.imaps.port}") int port, @Value("${mailserver.smtp.hostname:localhost}") String hostname, @@ -54,6 +55,6 @@ public class ImapConfig { @Value("${mailserver.imap.idle-timeout:30m}") Duration idleTimeout, @Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) { return new ImapServer(new ImapServer.Settings(host, port, hostname, false, tls.context(), true, - (int) maxLiteral.toBytes(), idleTimeout, idlePoll), store); + (int) maxLiteral.toBytes(), idleTimeout, idlePoll), store, guard); } } diff --git a/backend/src/main/java/com/mailserver/config/LoginGuardConfig.java b/backend/src/main/java/com/mailserver/config/LoginGuardConfig.java new file mode 100644 index 0000000..d229954 --- /dev/null +++ b/backend/src/main/java/com/mailserver/config/LoginGuardConfig.java @@ -0,0 +1,74 @@ +package com.mailserver.config; + +import com.mailserver.security.LoginGuard; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.adapter.ForwardedHeaderTransformer; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +/** Brute-force protection (one guard for SMTP, IMAP and the web) and whose X-Forwarded-* headers to believe. */ +@Configuration +public class LoginGuardConfig { + + @Bean + public LoginGuard loginGuard(@Value("${mailserver.login-guard.enabled:true}") boolean enabled, + @Value("${mailserver.login-guard.max-per-ip:10}") int maxPerIp, + @Value("${mailserver.login-guard.max-per-account:20}") int maxPerAccount, + @Value("${mailserver.login-guard.window:15m}") Duration window, + @Value("${mailserver.login-guard.block:15m}") Duration block, + @Value("${mailserver.login-guard.max-block:24h}") Duration maxBlock, + @Value("${mailserver.login-guard.known-ip-ttl:30d}") Duration knownIpTtl, + @Value("${mailserver.login-guard.trusted-ips:}") String trustedIps, + @Value("${mailserver.login-guard.failure-delay:1s}") Duration failureDelay) { + Set trusted = Arrays.stream(trustedIps.split(",")) + .map(String::trim).filter(s -> !s.isEmpty()).collect(Collectors.toUnmodifiableSet()); + return new LoginGuard(new LoginGuard.Settings(enabled, maxPerIp, maxPerAccount, window, block, maxBlock, + knownIpTtl, trusted, failureDelay)); + } + + /** + * Replaces Boot's transformer (server.forward-headers-strategy=framework): X-Forwarded-* / Forwarded are believed + * only from a proxy in a private network (Jambotron's Caddy comes over the docker network / WireGuard). A request + * straight from the internet (the VM's public address) has them removed — otherwise anyone could put a new + * X-Forwarded-For into every login attempt and never hit the per-IP limit. + */ + @Bean(name = "forwardedHeaderTransformer") + public ForwardedHeaderTransformer forwardedHeaderTransformer() { + return new TrustedProxyHeaderTransformer(); + } + + static final class TrustedProxyHeaderTransformer extends ForwardedHeaderTransformer { + + private final ForwardedHeaderTransformer stripOnly = new ForwardedHeaderTransformer(); + + TrustedProxyHeaderTransformer() { + stripOnly.setRemoveOnly(true); + } + + @Override + public ServerHttpRequest apply(ServerHttpRequest request) { + return privatePeer(request.getRemoteAddress()) ? super.apply(request) : stripOnly.apply(request); + } + } + + /** Loopback, 10/8, 172.16/12, 192.168/16, link-local, IPv6 fc00::/7. */ + static boolean privatePeer(InetSocketAddress peer) { + if (peer == null || peer.getAddress() == null) { + return false; + } + InetAddress a = peer.getAddress(); + if (a.isLoopbackAddress() || a.isSiteLocalAddress() || a.isLinkLocalAddress()) { + return true; + } + byte[] b = a.getAddress(); + return b.length == 16 && (b[0] & 0xfe) == 0xfc; + } +} diff --git a/backend/src/main/java/com/mailserver/config/MailServerConfig.java b/backend/src/main/java/com/mailserver/config/MailServerConfig.java index 70ad73c..f9d346e 100644 --- a/backend/src/main/java/com/mailserver/config/MailServerConfig.java +++ b/backend/src/main/java/com/mailserver/config/MailServerConfig.java @@ -1,5 +1,6 @@ package com.mailserver.config; +import com.mailserver.security.LoginGuard; import com.mailserver.delivery.SmtpDeliveryClient; import com.mailserver.smtp.MailRouter; import com.mailserver.smtp.SmtpServer; @@ -39,7 +40,7 @@ public class MailServerConfig { @Bean(initMethod = "start", destroyMethod = "stop") @ConditionalOnProperty(name = "mailserver.smtp.enabled", havingValue = "true", matchIfMissing = true) - public SmtpServer smtpServer(MailRouter router, SmtpTlsContext tls, + public SmtpServer smtpServer(MailRouter router, SmtpTlsContext tls, LoginGuard guard, @Value("${mailserver.smtp.host:0.0.0.0}") String host, @Value("${mailserver.smtp.port:3025}") int port, @Value("${mailserver.smtp.hostname:localhost}") String hostname, @@ -47,20 +48,20 @@ public class MailServerConfig { @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, - tls.context(), false), router); + tls.context(), false), router, guard); } /** 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, + public SmtpServer smtpsServer(MailRouter router, SmtpTlsContext tls, LoginGuard guard, @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); + tls.context(), true), router, guard); } /** diff --git a/backend/src/main/java/com/mailserver/controller/AuthenticationController.java b/backend/src/main/java/com/mailserver/controller/AuthenticationController.java index c93e879..d1f495e 100644 --- a/backend/src/main/java/com/mailserver/controller/AuthenticationController.java +++ b/backend/src/main/java/com/mailserver/controller/AuthenticationController.java @@ -10,11 +10,16 @@ import com.mailserver.dto.ProfileUpdateRequest; import com.mailserver.dto.RegisterRequest; import com.mailserver.service.MailService; import com.mailserver.service.UserService; +import com.mailserver.security.LoginGuard; import com.mailserver.util.JwtUtils; import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -25,6 +30,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import reactor.core.publisher.Mono; +import java.net.InetSocketAddress; +import java.time.Duration; import java.util.Locale; import java.util.Map; import java.util.Set; @@ -34,6 +41,10 @@ import java.util.Set; @RequestMapping("/api") public class AuthenticationController { + private static final Logger log = LoggerFactory.getLogger(AuthenticationController.class); + /** Marker for "no such mailbox or wrong password": both look the same from outside. */ + private static final User NO_USER = new User(); + /** Service addresses nobody can register (RFC 2142 and ours). */ static final Set RESERVED = Set.of("postmaster", "abuse", "mailer-daemon", "hostmaster", "webmaster", "root", "admin", "noreply", "no-reply", "security", "support"); @@ -43,9 +54,12 @@ public class AuthenticationController { private final JwtUtils jwtUtils; private final MailDomains domains; private final boolean registrationEnabled; + private final LoginGuard guard; public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains, - @Value("${mailserver.registration.enabled:false}") boolean registrationEnabled) { + @Value("${mailserver.registration.enabled:false}") boolean registrationEnabled, + LoginGuard guard) { + this.guard = guard; this.userService = userService; this.mailService = mailService; this.jwtUtils = jwtUtils; @@ -60,23 +74,34 @@ public class AuthenticationController { } @PostMapping("/login") - public Mono> login(@Valid @RequestBody Mono requestMono) { - return requestMono.flatMap(request -> - userService.findByEmail(request.getEmail().trim().toLowerCase(Locale.ROOT)) - .switchIfEmpty(Mono.error(new IllegalArgumentException("Invalid credentials"))) + public Mono> login(@Valid @RequestBody Mono requestMono, ServerHttpRequest http) { + String ip = clientIp(http); + return requestMono.flatMap(request -> { + String email = request.getEmail().trim().toLowerCase(Locale.ROOT); + Duration wait = guard.retryAfter(ip, email); + if (!wait.isZero()) { + log.warn("WEB {}: login as {} refused, blocked for {}", ip, email, LoginGuard.human(wait)); + return Mono.just(tooMany(wait)); + } + return userService.findByEmail(email) + .map(user -> userService.checkPassword(user, request.getPassword()) ? user : NO_USER) + .onErrorResume(IllegalArgumentException.class, e -> Mono.just(NO_USER)) + .defaultIfEmpty(NO_USER) .flatMap(user -> { + if (user == NO_USER) { + guard.failure(ip, email); + log.warn("WEB {}: authentication failed for {}", ip, email); + Mono> answer = Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"))); + Duration delay = guard.failureDelay(); + return delay.isZero() ? answer : answer.delayElement(delay); + } + guard.success(ip, email); if (!user.isActive()) { return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated"))); } - if (!userService.checkPassword(user, request.getPassword())) { - return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"))); - } return Mono.just(ResponseEntity.ok(token(user))); - }) - .onErrorResume(IllegalArgumentException.class, e -> - Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"))) - ) - ); + }); + }); } /** New mailbox in one of our domains; logs in right away (the answer carries the token). */ @@ -116,12 +141,41 @@ public class AuthenticationController { } @PutMapping("/me/password") - public Mono> changePassword(Mono auth, @Valid @RequestBody Mono body) { + public Mono> changePassword(Mono auth, @Valid @RequestBody Mono body, + ServerHttpRequest http) { + String ip = clientIp(http); return user(auth).zipWith(body) - .flatMap(pair -> userService.changePassword(pair.getT1(), pair.getT2().currentPassword(), pair.getT2().newPassword())) + .flatMap(pair -> { + User user = pair.getT1(); + // Someone with a stolen session must not guess the current password either + Duration wait = guard.retryAfter(ip, user.getEmail()); + if (!wait.isZero()) { + return Mono.error(new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, + "Too many failed attempts, try again in " + LoginGuard.human(wait))); + } + if (!userService.checkPassword(user, pair.getT2().currentPassword())) { + guard.failure(ip, user.getEmail()); + } + return userService.changePassword(user, pair.getT2().currentPassword(), pair.getT2().newPassword()); + }) .thenReturn(Map.of("message", "Password changed")); } + private static ResponseEntity tooMany(Duration wait) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .header(HttpHeaders.RETRY_AFTER, String.valueOf(Math.max(1, (wait.toMillis() + 999) / 1000))) + .body(Map.of("error", "Too many failed logins, try again in " + LoginGuard.human(wait))); + } + + /** Client address; behind Caddy it comes from X-Forwarded-For (server.forward-headers-strategy=framework). */ + static String clientIp(ServerHttpRequest http) { + InetSocketAddress address = http.getRemoteAddress(); + if (address == null) { + return "unknown"; + } + return address.getAddress() != null ? address.getAddress().getHostAddress() : address.getHostString(); + } + private Mono profile(User user) { return mailService.usedBytes(user.getId()) .map(used -> new ProfileResponse(user.getEmail(), user.getDisplayName(), used, diff --git a/backend/src/main/java/com/mailserver/imap/ImapServer.java b/backend/src/main/java/com/mailserver/imap/ImapServer.java index 36a0564..028c1b8 100644 --- a/backend/src/main/java/com/mailserver/imap/ImapServer.java +++ b/backend/src/main/java/com/mailserver/imap/ImapServer.java @@ -1,5 +1,6 @@ package com.mailserver.imap; +import com.mailserver.security.LoginGuard; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandlerContext; @@ -67,11 +68,17 @@ public class ImapServer { private final Settings settings; private final ImapStore store; + private final LoginGuard guard; private DisposableServer server; public ImapServer(Settings settings, ImapStore store) { + this(settings, store, LoginGuard.disabled()); + } + + public ImapServer(Settings settings, ImapStore store, LoginGuard guard) { this.settings = settings; this.store = store; + this.guard = guard; } public void start() { @@ -98,7 +105,7 @@ public class ImapServer { }); ImapSession session = new ImapSession( new ImapSession.Settings(settings.hostname(), settings.tls() != null, settings.authWithoutTls()), - store, remoteIp[0], settings.implicitTls()); + store, guard, remoteIp[0], settings.implicitTls()); @SuppressWarnings("unchecked") Sinks.Empty[] idleDone = new Sinks.Empty[1]; @@ -135,7 +142,9 @@ public class ImapServer { log.debug("IMAP {}: connection closed: {}", session.remoteIp, e.toString()); return Mono.just(ImapSession.line("* BYE " + e.getMessage())); }) - .takeUntil(response -> session.closing); + // after the tagged line of the command that closes (LOGOUT, too many failed logins), not after + // its first untagged "* BYE" — the tagged answer must still reach the client + .takeUntil(response -> session.closing && !untagged(response)); return Flux.concat(Mono.just(session.greeting()), responses) .concatMap(response -> response == ImapSession.STARTTLS_READY @@ -164,6 +173,10 @@ public class ImapServer { return new String(bytes, StandardCharsets.US_ASCII).trim().toUpperCase(Locale.ROOT).equals("DONE"); } + private static boolean untagged(byte[] response) { + return response.length > 1 && response[0] == '*' && response[1] == ' '; + } + private static Flux blocking(java.util.concurrent.Callable> work) { return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic()).flatMapIterable(list -> list); } diff --git a/backend/src/main/java/com/mailserver/imap/ImapSession.java b/backend/src/main/java/com/mailserver/imap/ImapSession.java index 8c5ee7b..643aee4 100644 --- a/backend/src/main/java/com/mailserver/imap/ImapSession.java +++ b/backend/src/main/java/com/mailserver/imap/ImapSession.java @@ -1,5 +1,6 @@ package com.mailserver.imap; +import com.mailserver.security.LoginGuard; import com.mailserver.service.LocalDelivery; import jakarta.mail.Session; import jakarta.mail.internet.MimeMessage; @@ -9,6 +10,7 @@ import org.slf4j.LoggerFactory; import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; @@ -39,6 +41,8 @@ final class ImapSession { private static final Logger log = LoggerFactory.getLogger(ImapSession.class); private static final int MAX_BAD_COMMANDS = 30; + /** Wrong passwords in one connection before it is closed. */ + private static final int MAX_AUTH_FAILURES = 3; private static final int SOURCE_CACHE = 16; private static final DateTimeFormatter INTERNAL_DATE = DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm:ss Z", Locale.ENGLISH); @@ -53,6 +57,7 @@ final class ImapSession { private final Settings settings; private final ImapStore store; + private final LoginGuard guard; final String remoteIp; boolean tls; @@ -78,8 +83,13 @@ final class ImapSession { volatile String idleTag; ImapSession(Settings settings, ImapStore store, String remoteIp, boolean tls) { + this(settings, store, LoginGuard.disabled(), remoteIp, tls); + } + + ImapSession(Settings settings, ImapStore store, LoginGuard guard, String remoteIp, boolean tls) { this.settings = settings; this.store = store; + this.guard = guard; this.remoteIp = remoteIp; this.tls = tls; } @@ -254,7 +264,7 @@ final class ImapSession { out.add(line(tag + " NO [PRIVACYREQUIRED] Use STARTTLS first")); return; } - loggedIn(tag, store.authenticate(user, password), user, out); + checkPassword(tag, user, password, out); } private void authenticate(String tag, ImapReader in, List out) { @@ -292,13 +302,44 @@ final class ImapSession { bad(tag, "Bad PLAIN response", out); return; } - loggedIn(tag, store.authenticate(fields[1], fields[2]), fields[1], out); + checkPassword(tag, fields[1], fields[2], out); + } + + /** Through the login guard: a blocked IP/mailbox gets no answer about the password and the connection closes. */ + private void checkPassword(String tag, String user, String password, List out) { + Duration wait = guard.retryAfter(remoteIp, user); + if (!wait.isZero()) { + log.warn("IMAP {}: login as {} refused, blocked for {}", remoteIp, user, LoginGuard.human(wait)); + out.add(line("* BYE Too many failed logins, try again in " + LoginGuard.human(wait))); + out.add(line(tag + " NO [UNAVAILABLE] Too many failed logins")); + closing = true; + return; + } + ImapStore.Account result = store.authenticate(user, password); + if (result == null) { + guard.failure(remoteIp, user); + pause(guard.failureDelay()); + } else { + guard.success(remoteIp, user); + } + loggedIn(tag, result, user, out); + } + + private static void pause(Duration delay) { + if (delay.isZero()) { + return; + } + try { + Thread.sleep(delay.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } } private void loggedIn(String tag, ImapStore.Account result, String user, List out) { if (result == null) { log.warn("IMAP {}: authentication failed for {}", remoteIp, user); - if (++badCommands > 5) { + if (++badCommands >= MAX_AUTH_FAILURES) { out.add(line("* BYE Too many failed logins")); closing = true; } diff --git a/backend/src/main/java/com/mailserver/security/LoginGuard.java b/backend/src/main/java/com/mailserver/security/LoginGuard.java new file mode 100644 index 0000000..e42350e --- /dev/null +++ b/backend/src/main/java/com/mailserver/security/LoginGuard.java @@ -0,0 +1,257 @@ +package com.mailserver.security; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Brute-force protection for every place that checks a password: SMTP AUTH, IMAP LOGIN/AUTHENTICATE, web login, + * password change. One instance is shared by all of them, so failures on SMTP and IMAP add up. + *
    + *
  • By IP: {@code maxPerIp} failures within {@code window} → the IP is blocked for {@code block}; + * each next block of the same IP is twice as long, up to {@code maxBlock}.
  • + *
  • By mailbox: {@code maxPerAccount} failures within {@code window} from any IPs (a distributed attack on + * one mailbox) → the mailbox is blocked for {@code block} — but not for IPs that logged in to it successfully + * before ({@code knownIpTtl}), so an attacker cannot lock the owner's own mail program out.
  • + *
+ * While blocked the password is not checked at all (no answer to a guess) and the caller gets {@link #retryAfter}. + * State is in memory: a restart forgets it, which is fine for minutes-long blocks. + */ +public class LoginGuard { + + private static final Logger log = LoggerFactory.getLogger(LoginGuard.class); + + /** Upper bound of tracked keys of each kind, so a flood of random usernames/IPs cannot eat the memory. */ + static final int MAX_KEYS = 100_000; + private static final int MAX_KNOWN_IPS_PER_ACCOUNT = 20; + + public record Settings(boolean enabled, int maxPerIp, int maxPerAccount, Duration window, Duration block, + Duration maxBlock, Duration knownIpTtl, Set trustedIps, Duration failureDelay) { + + public static Settings defaults() { + return new Settings(true, 10, 20, Duration.ofMinutes(15), Duration.ofMinutes(15), Duration.ofHours(24), + Duration.ofDays(30), Set.of(), Duration.ofSeconds(1)); + } + } + + /** Failures in the window, current block and how many times the key was blocked in a row. */ + private static final class Counter { + final Deque failures = new ArrayDeque<>(); + volatile Instant blockedUntil = Instant.EPOCH; + int strikes; + Instant lastSeen = Instant.EPOCH; + } + + private final Settings settings; + private final Clock clock; + private final Map byIp = new ConcurrentHashMap<>(); + private final Map byAccount = new ConcurrentHashMap<>(); + /** account → IPs that logged in to it successfully, with the time of the last success. */ + private final Map> knownIps = new ConcurrentHashMap<>(); + + public LoginGuard(Settings settings) { + this(settings, Clock.systemUTC()); + } + + LoginGuard(Settings settings, Clock clock) { + this.settings = settings; + this.clock = clock; + } + + /** Guard that never blocks (tests, closed networks). */ + public static LoginGuard disabled() { + Settings d = Settings.defaults(); + return new LoginGuard(new Settings(false, d.maxPerIp(), d.maxPerAccount(), d.window(), d.block(), d.maxBlock(), + d.knownIpTtl(), Set.of(), Duration.ZERO)); + } + + /** Pause before answering "wrong password": slows down guessing inside one connection. */ + public Duration failureDelay() { + return settings.enabled() ? settings.failureDelay() : Duration.ZERO; + } + + /** + * Zero when the attempt may go on and the password may be checked; otherwise how long the caller has to wait. + */ + public Duration retryAfter(String ip, String account) { + if (!settings.enabled()) { + return Duration.ZERO; + } + Instant now = clock.instant(); + Duration wait = Duration.ZERO; + if (!trusted(ip)) { + wait = remaining(byIp.get(ip), now); + } + String acc = normalize(account); + if (acc != null && !known(acc, ip, now)) { + Duration accountWait = remaining(byAccount.get(acc), now); + if (accountWait.compareTo(wait) > 0) { + wait = accountWait; + } + } + return wait; + } + + public boolean blocked(String ip, String account) { + return !retryAfter(ip, account).isZero(); + } + + /** A wrong password (or an unknown mailbox). */ + public void failure(String ip, String account) { + if (!settings.enabled()) { + return; + } + Instant now = clock.instant(); + if (!trusted(ip)) { + count(byIp, ip, settings.maxPerIp(), now, "IP " + ip); + } + String acc = normalize(account); + if (acc != null) { + count(byAccount, acc, settings.maxPerAccount(), now, "mailbox " + acc); + } + } + + /** A right password: the IP becomes known for the mailbox, failures of this IP are forgiven. */ + public void success(String ip, String account) { + if (!settings.enabled()) { + return; + } + Instant now = clock.instant(); + String acc = normalize(account); + if (acc != null) { + if (knownIps.size() < MAX_KEYS || knownIps.containsKey(acc)) { + knownIps.compute(acc, (k, ips) -> { + Map map = ips != null ? ips : new LinkedHashMap<>(); + synchronized (map) { + map.remove(ip); + map.put(ip, now); + while (map.size() > MAX_KNOWN_IPS_PER_ACCOUNT) { + map.remove(map.keySet().iterator().next()); + } + } + return map; + }); + } + } + byIp.computeIfPresent(ip, (k, c) -> { + c.failures.clear(); + return c.blockedUntil.isAfter(now) ? c : null; + }); + } + + /** Drops what expired; runs every minute. */ + @Scheduled(fixedDelay = 60_000) + public void sweep() { + Instant now = clock.instant(); + Instant idle = now.minus(settings.maxBlock()).minus(settings.window()); + byIp.entrySet().removeIf(e -> stale(e.getValue(), now, idle)); + byAccount.entrySet().removeIf(e -> stale(e.getValue(), now, idle)); + Instant knownSince = now.minus(settings.knownIpTtl()); + knownIps.entrySet().removeIf(e -> { + synchronized (e.getValue()) { + e.getValue().values().removeIf(t -> t.isBefore(knownSince)); + return e.getValue().isEmpty(); + } + }); + } + + int trackedIps() { + return byIp.size(); + } + + int trackedAccounts() { + return byAccount.size(); + } + + // --------------------------------------------------------------------------------------------- + + private void count(Map map, String key, int max, Instant now, String what) { + if (map.size() >= MAX_KEYS && !map.containsKey(key)) { + sweep(); + if (map.size() >= MAX_KEYS) { + log.warn("login guard: {} keys tracked, {} not counted", map.size(), what); + return; + } + } + map.compute(key, (k, existing) -> { + Counter c = existing != null ? existing : new Counter(); + c.lastSeen = now; + Instant from = now.minus(settings.window()); + while (!c.failures.isEmpty() && c.failures.peekFirst().isBefore(from)) { + c.failures.pollFirst(); + } + if (c.blockedUntil.isAfter(now)) { + return c; // already blocked: attempts are refused before the password check anyway + } + // A long quiet time after the last block forgives the strikes + if (c.strikes > 0 && c.blockedUntil.plus(settings.maxBlock()).isBefore(now)) { + c.strikes = 0; + } + c.failures.addLast(now); + if (c.failures.size() >= max) { + Duration block = settings.block().multipliedBy(1L << Math.min(c.strikes, 20)); + if (block.compareTo(settings.maxBlock()) > 0) { + block = settings.maxBlock(); + } + c.strikes++; + c.blockedUntil = now.plus(block); + c.failures.clear(); + log.warn("login guard: {} blocked for {} after {} failed logins (block #{})", + what, human(block), max, c.strikes); + } + return c; + }); + } + + private static Duration remaining(Counter c, Instant now) { + if (c == null) { + return Duration.ZERO; + } + Instant until = c.blockedUntil; + return until.isAfter(now) ? Duration.between(now, until) : Duration.ZERO; + } + + private boolean known(String account, String ip, Instant now) { + Map ips = knownIps.get(account); + if (ips == null) { + return false; + } + synchronized (ips) { + Instant at = ips.get(ip); + return at != null && at.plus(settings.knownIpTtl()).isAfter(now); + } + } + + private boolean trusted(String ip) { + return settings.trustedIps().contains(ip); + } + + private static boolean stale(Counter c, Instant now, Instant idle) { + return !c.blockedUntil.isAfter(now) && c.lastSeen.isBefore(idle); + } + + private static String normalize(String account) { + if (account == null) { + return null; + } + String acc = account.trim().toLowerCase(Locale.ROOT); + return acc.isEmpty() ? null : acc; + } + + /** "15 min" / "2 h" for messages to the user. */ + public static String human(Duration d) { + long minutes = Math.max(1, (d.toSeconds() + 59) / 60); + return minutes < 120 ? minutes + " min" : (minutes + 59) / 60 + " h"; + } +} diff --git a/backend/src/main/java/com/mailserver/smtp/SmtpServer.java b/backend/src/main/java/com/mailserver/smtp/SmtpServer.java index 7c18a13..fd07a18 100644 --- a/backend/src/main/java/com/mailserver/smtp/SmtpServer.java +++ b/backend/src/main/java/com/mailserver/smtp/SmtpServer.java @@ -1,5 +1,6 @@ package com.mailserver.smtp; +import com.mailserver.security.LoginGuard; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufUtil; import io.netty.channel.ChannelHandlerContext; @@ -54,6 +55,8 @@ public class SmtpServer { static final int MAX_LINE = 64 * 1024; static final int MAX_RECIPIENTS = 100; private static final int MAX_BAD_COMMANDS = 20; + /** Wrong passwords in one connection before it is closed. */ + private static final int MAX_AUTH_FAILURES = 3; // 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"); @@ -84,11 +87,17 @@ public class SmtpServer { private final Settings settings; private final SmtpBackend backend; + private final LoginGuard guard; private DisposableServer server; public SmtpServer(Settings settings, SmtpBackend backend) { + this(settings, backend, LoginGuard.disabled()); + } + + public SmtpServer(Settings settings, SmtpBackend backend, LoginGuard guard) { this.settings = settings; this.backend = backend; + this.guard = guard; } public void start() { @@ -183,6 +192,7 @@ public class SmtpServer { final ByteArrayOutputStream data = new ByteArrayOutputStream(); boolean dataTooBig; int badCommands; + int authFailures; volatile boolean closing; Session(String remoteIp, boolean tls) { @@ -356,16 +366,32 @@ public class SmtpServer { private Mono checkPassword(String user, String password) { String username = user.trim().toLowerCase(Locale.ROOT); + Duration wait = guard.retryAfter(remoteIp, username); + if (!wait.isZero()) { + // Blocked: the password is not even checked, the connection is closed + log.warn("SMTP {}: login as {} refused, blocked for {}", remoteIp, username, LoginGuard.human(wait)); + closing = true; + return reply("421 4.7.0 Too many failed logins, try again in " + LoginGuard.human(wait)); + } return blocking(() -> backend.authenticate(username, password)) - .map(ok -> { + .flatMap(ok -> { if (ok) { + guard.success(remoteIp, username); authenticatedUser = username; log.info("SMTP {}: authenticated as {}", remoteIp, username); - return "235 2.7.0 Authentication successful\r\n"; + return Mono.just("235 2.7.0 Authentication successful\r\n"); } + guard.failure(remoteIp, username); log.warn("SMTP {}: authentication failed for {}", remoteIp, username); badCommands++; - return "535 5.7.8 Authentication credentials invalid\r\n"; + if (++authFailures >= MAX_AUTH_FAILURES) { + closing = true; + } + String answer = closing + ? "535 5.7.8 Authentication credentials invalid, closing connection\r\n" + : "535 5.7.8 Authentication credentials invalid\r\n"; + Duration delay = guard.failureDelay(); + return delay.isZero() ? Mono.just(answer) : Mono.just(answer).delayElement(delay); }); } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 3293b05..39d4a55 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -83,3 +83,15 @@ management.endpoints.web.exposure.include=health management.endpoint.health.show-details=never logging.level.com.mailserver=INFO + +# Brute-force protection for SMTP AUTH, IMAP login, web login and password change (one counter for all of them): +# MAX_PER_IP wrong passwords from one IP within WINDOW → the IP is blocked for BLOCK (each next block twice as long, +# up to MAX_BLOCK); MAX_PER_ACCOUNT wrong passwords to one mailbox from any IPs → the mailbox is blocked for BLOCK, +# except for IPs that logged in to it before. TRUSTED_IPS (comma-separated) are never blocked by IP +mailserver.login-guard.enabled=${LOGIN_GUARD:true} +mailserver.login-guard.max-per-ip=${LOGIN_GUARD_MAX_PER_IP:10} +mailserver.login-guard.max-per-account=${LOGIN_GUARD_MAX_PER_ACCOUNT:20} +mailserver.login-guard.window=${LOGIN_GUARD_WINDOW:15m} +mailserver.login-guard.block=${LOGIN_GUARD_BLOCK:15m} +mailserver.login-guard.max-block=${LOGIN_GUARD_MAX_BLOCK:24h} +mailserver.login-guard.trusted-ips=${LOGIN_GUARD_TRUSTED_IPS:} diff --git a/backend/src/test/java/com/mailserver/config/LoginGuardConfigTest.java b/backend/src/test/java/com/mailserver/config/LoginGuardConfigTest.java new file mode 100644 index 0000000..5e8cb19 --- /dev/null +++ b/backend/src/test/java/com/mailserver/config/LoginGuardConfigTest.java @@ -0,0 +1,46 @@ +package com.mailserver.config; + +import org.junit.jupiter.api.Test; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; + +import java.net.InetSocketAddress; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoginGuardConfigTest { + + private final LoginGuardConfig.TrustedProxyHeaderTransformer transformer = new LoginGuardConfig.TrustedProxyHeaderTransformer(); + + private ServerHttpRequest from(String peer) { + return MockServerHttpRequest.post("/api/login") + .remoteAddress(new InetSocketAddress(peer, 40000)) + .header("X-Forwarded-For", "198.51.100.9") + .header("X-Forwarded-Proto", "https") + .build(); + } + + @Test + void forwardedForIsBelievedFromAPrivateProxy() { + ServerHttpRequest request = transformer.apply(from("172.18.0.5")); + assertThat(request.getRemoteAddress().getHostString()).isEqualTo("198.51.100.9"); + assertThat(request.getURI().getScheme()).isEqualTo("https"); + } + + @Test + void forwardedForFromTheInternetIsDropped() { + ServerHttpRequest request = transformer.apply(from("203.0.113.7")); + assertThat(request.getRemoteAddress().getAddress().getHostAddress()).isEqualTo("203.0.113.7"); + assertThat(request.getHeaders().containsKey("X-Forwarded-For")).isFalse(); + } + + @Test + void privateRanges() { + for (String ip : new String[]{"127.0.0.1", "10.30.0.13", "172.16.0.1", "192.168.1.1", "fd00::1", "::1", "fe80::1"}) { + assertThat(LoginGuardConfig.privatePeer(new InetSocketAddress(ip, 1))).as(ip).isTrue(); + } + for (String ip : new String[]{"103.109.235.154", "172.32.0.1", "2001:db8::1", "8.8.8.8"}) { + assertThat(LoginGuardConfig.privatePeer(new InetSocketAddress(ip, 1))).as(ip).isFalse(); + } + } +} diff --git a/backend/src/test/java/com/mailserver/controller/AuthenticationControllerTest.java b/backend/src/test/java/com/mailserver/controller/AuthenticationControllerTest.java new file mode 100644 index 0000000..bf56d4c --- /dev/null +++ b/backend/src/test/java/com/mailserver/controller/AuthenticationControllerTest.java @@ -0,0 +1,81 @@ +package com.mailserver.controller; + +import com.mailserver.config.MailDomains; +import com.mailserver.domain.User; +import com.mailserver.dto.LoginRequest; +import com.mailserver.security.LoginGuard; +import com.mailserver.service.MailService; +import com.mailserver.service.UserService; +import com.mailserver.util.JwtUtils; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import reactor.core.publisher.Mono; + +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AuthenticationControllerTest { + + private final UserService users = mock(UserService.class); + private final JwtUtils jwt = mock(JwtUtils.class); + private final LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 3, 100, Duration.ofMinutes(15), + Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(), Duration.ZERO)); + private final AuthenticationController controller = new AuthenticationController(users, mock(MailService.class), jwt, + mock(MailDomains.class), false, guard); + + private final User alice = new User(); + + AuthenticationControllerTest() { + alice.setEmail("alice@x.test"); + alice.setPassword("hash"); + when(users.findByEmail("alice@x.test")).thenReturn(Mono.just(alice)); + when(users.findByEmail("ghost@x.test")).thenReturn(Mono.empty()); + when(users.checkPassword(eq(alice), anyString())).thenAnswer(i -> "secret".equals(i.getArgument(1))); + when(jwt.generateToken(any())).thenReturn("token"); + } + + private ResponseEntity login(String ip, String email, String password) { + LoginRequest request = new LoginRequest(); + request.setEmail(email); + request.setPassword(password); + MockServerHttpRequest http = MockServerHttpRequest.post("/api/login") + .remoteAddress(new InetSocketAddress(ip, 40000)).build(); + return controller.login(Mono.just(request), http).block(); + } + + @Test + void unknownMailboxAndWrongPasswordLookTheSameAndBothCount() { + assertThat(login("1.2.3.4", "ghost@x.test", "x").getBody()).isEqualTo(Map.of("error", "Invalid credentials")); + assertThat(login("1.2.3.4", "alice@x.test", "x").getBody()).isEqualTo(Map.of("error", "Invalid credentials")); + login("1.2.3.4", "alice@x.test", "y"); + + ResponseEntity blocked = login("1.2.3.4", "alice@x.test", "secret"); + assertThat(blocked.getStatusCode()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS); + assertThat(blocked.getHeaders().getFirst("Retry-After")).isEqualTo("900"); + assertThat(blocked.getBody()).isEqualTo(Map.of("error", "Too many failed logins, try again in 15 min")); + + assertThat(login("5.6.7.8", "alice@x.test", "secret").getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void blockedAttemptDoesNotCheckThePassword() { + for (int i = 0; i < 3; i++) { + login("1.2.3.4", "ghost@x.test", "x"); + } + login("1.2.3.4", "alice@x.test", "secret"); + verify(users, never()).checkPassword(any(), eq("secret")); + } +} diff --git a/backend/src/test/java/com/mailserver/imap/ImapServerTest.java b/backend/src/test/java/com/mailserver/imap/ImapServerTest.java index 0463c34..df098ad 100644 --- a/backend/src/test/java/com/mailserver/imap/ImapServerTest.java +++ b/backend/src/test/java/com/mailserver/imap/ImapServerTest.java @@ -14,6 +14,7 @@ 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; @@ -184,6 +185,33 @@ class ImapServerTest { } } + @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())) { diff --git a/backend/src/test/java/com/mailserver/security/LoginGuardTest.java b/backend/src/test/java/com/mailserver/security/LoginGuardTest.java new file mode 100644 index 0000000..efad7da --- /dev/null +++ b/backend/src/test/java/com/mailserver/security/LoginGuardTest.java @@ -0,0 +1,164 @@ +package com.mailserver.security; + +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoginGuardTest { + + /** Clock the test moves by hand. */ + static final class TestClock extends Clock { + Instant now = Instant.parse("2026-11-06T12:00:00Z"); + + void plus(Duration d) { + now = now.plus(d); + } + + @Override + public ZoneOffset getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now; + } + } + + private final TestClock clock = new TestClock(); + + private LoginGuard guard(int perIp, int perAccount, String... trusted) { + return new LoginGuard(new LoginGuard.Settings(true, perIp, perAccount, Duration.ofMinutes(15), + Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(trusted), Duration.ZERO), clock); + } + + @Test + void ipIsBlockedAfterTheLimitWhateverMailboxItTries() { + LoginGuard guard = guard(3, 100); + for (int i = 0; i < 2; i++) { + guard.failure("1.2.3.4", "user" + i + "@x.test"); + } + assertThat(guard.blocked("1.2.3.4", "any@x.test")).isFalse(); + guard.failure("1.2.3.4", "user9@x.test"); + assertThat(guard.retryAfter("1.2.3.4", "any@x.test")).isEqualTo(Duration.ofMinutes(15)); + assertThat(guard.blocked("5.6.7.8", "any@x.test")).isFalse(); + + clock.plus(Duration.ofMinutes(15)); + assertThat(guard.blocked("1.2.3.4", "any@x.test")).isFalse(); + } + + @Test + void failuresOutsideTheWindowDoNotCount() { + LoginGuard guard = guard(3, 100); + guard.failure("1.2.3.4", "a@x.test"); + guard.failure("1.2.3.4", "a@x.test"); + clock.plus(Duration.ofMinutes(16)); + guard.failure("1.2.3.4", "a@x.test"); + assertThat(guard.blocked("1.2.3.4", "a@x.test")).isFalse(); + } + + @Test + void eachNextBlockOfTheSameIpIsTwiceAsLongUpToTheMaximum() { + LoginGuard guard = guard(2, 100); + Duration expected = Duration.ofMinutes(15); + for (int round = 0; round < 10; round++) { + guard.failure("1.2.3.4", "a@x.test"); + guard.failure("1.2.3.4", "a@x.test"); + Duration wait = guard.retryAfter("1.2.3.4", "b@x.test"); + assertThat(wait).isEqualTo(expected); + clock.plus(wait); + expected = expected.multipliedBy(2).compareTo(Duration.ofHours(24)) > 0 ? Duration.ofHours(24) : expected.multipliedBy(2); + } + // a quiet day forgives + clock.plus(Duration.ofHours(25)); + guard.failure("1.2.3.4", "a@x.test"); + guard.failure("1.2.3.4", "a@x.test"); + assertThat(guard.retryAfter("1.2.3.4", "b@x.test")).isEqualTo(Duration.ofMinutes(15)); + } + + @Test + void attemptsWhileBlockedDoNotProlongTheBlock() { + LoginGuard guard = guard(2, 100); + guard.failure("1.2.3.4", "a@x.test"); + guard.failure("1.2.3.4", "a@x.test"); + clock.plus(Duration.ofMinutes(10)); + guard.failure("1.2.3.4", "a@x.test"); + assertThat(guard.retryAfter("1.2.3.4", "a@x.test")).isEqualTo(Duration.ofMinutes(5)); + } + + @Test + void distributedAttackBlocksTheMailboxButNotItsOwnerFromAKnownIp() { + LoginGuard guard = guard(100, 5); + guard.success("10.0.0.7", "Alice@X.test"); + for (int i = 0; i < 5; i++) { + guard.failure("203.0.113." + i, "alice@x.test"); + } + assertThat(guard.blocked("198.51.100.1", "alice@x.test")).isTrue(); + assertThat(guard.blocked("198.51.100.1", "bob@x.test")).isFalse(); + assertThat(guard.blocked("10.0.0.7", "alice@x.test")).isFalse(); + } + + @Test + void successForgivesTheIpFailuresButNotAnActiveBlock() { + LoginGuard guard = guard(3, 100); + guard.failure("1.2.3.4", "a@x.test"); + guard.failure("1.2.3.4", "a@x.test"); + guard.success("1.2.3.4", "a@x.test"); + guard.failure("1.2.3.4", "a@x.test"); + guard.failure("1.2.3.4", "a@x.test"); + assertThat(guard.blocked("1.2.3.4", "a@x.test")).isFalse(); + guard.failure("1.2.3.4", "a@x.test"); + assertThat(guard.blocked("1.2.3.4", "a@x.test")).isTrue(); + guard.success("1.2.3.4", "a@x.test"); + assertThat(guard.blocked("1.2.3.4", "other@x.test")).isTrue(); + } + + @Test + void trustedIpIsNeverBlockedByIp() { + LoginGuard guard = guard(2, 100, "172.18.0.1"); + for (int i = 0; i < 10; i++) { + guard.failure("172.18.0.1", "u" + i + "@x.test"); + } + assertThat(guard.blocked("172.18.0.1", "new@x.test")).isFalse(); + } + + @Test + void sweepForgetsOldKeys() { + LoginGuard guard = guard(5, 5); + guard.failure("1.2.3.4", "a@x.test"); + assertThat(guard.trackedIps()).isEqualTo(1); + assertThat(guard.trackedAccounts()).isEqualTo(1); + clock.plus(Duration.ofHours(25).plusMinutes(16)); + guard.sweep(); + assertThat(guard.trackedIps()).isZero(); + assertThat(guard.trackedAccounts()).isZero(); + } + + @Test + void disabledGuardNeverBlocks() { + LoginGuard guard = LoginGuard.disabled(); + for (int i = 0; i < 100; i++) { + guard.failure("1.2.3.4", "a@x.test"); + } + assertThat(guard.blocked("1.2.3.4", "a@x.test")).isFalse(); + assertThat(guard.failureDelay()).isZero(); + } + + @Test + void humanDuration() { + assertThat(LoginGuard.human(Duration.ofSeconds(30))).isEqualTo("1 min"); + assertThat(LoginGuard.human(Duration.ofMinutes(15))).isEqualTo("15 min"); + assertThat(LoginGuard.human(Duration.ofHours(4))).isEqualTo("4 h"); + } +} diff --git a/backend/src/test/java/com/mailserver/smtp/SmtpServerTest.java b/backend/src/test/java/com/mailserver/smtp/SmtpServerTest.java index 9aaf68e..d998df1 100644 --- a/backend/src/test/java/com/mailserver/smtp/SmtpServerTest.java +++ b/backend/src/test/java/com/mailserver/smtp/SmtpServerTest.java @@ -5,6 +5,7 @@ import jakarta.mail.Session; import jakarta.mail.Transport; import jakarta.mail.internet.InternetAddress; import jakarta.mail.internet.MimeMessage; +import com.mailserver.security.LoginGuard; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -158,6 +159,34 @@ class SmtpServerTest { assertThat(backend.messages.get(0)).contains("with ESMTPA;"); } + @Test + void threeWrongPasswordsCloseTheConnectionAndTheGuardBlocksTheIp() 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 SmtpServer(new SmtpServer.Settings("127.0.0.1", 0, "mx.example.test", 2000, true, + Duration.ofSeconds(30)), backend, guard); + server.start(); + try (Client client = new Client(server.port())) { + client.read(); + client.send("EHLO laptop"); + client.readReplyLines(); + assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0w1"))).startsWith("535"); + assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0w2"))).startsWith("535"); + assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0w3"))).startsWith("535").contains("closing"); + assertThat(client.read()).isNull(); + } + try (Client client = new Client(server.port())) { + client.read(); + client.send("EHLO laptop"); + client.readReplyLines(); + assertThat(client.send("AUTH PLAIN " + b64("\0bob@example.test\0w4"))).startsWith("535"); + // 4th failure from 127.0.0.1: blocked — even the right password is not checked + assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("421 4.7.0"); + assertThat(client.read()).isNull(); + } + } + @Test void authLoginWorksToo() throws Exception { try (Client client = new Client(server.port())) {