Compare commits

...
Author SHA1 Message Date
Liosha84 048d6b3679 Merge pull request 'docker-compose.public.yml: значения по умолчанию для jambocheck.com (деплой без .env)' (#15) from fix/public-compose-defaults into master
continuous-integration/drone/push Build is passing
Reviewed-on: #15
2026-11-06 20:20:21 +01:00
Liosha84andClaude Opus 5.5 2a422f638f docker-compose.public.yml: наши значения по умолчанию (mail.jambocheck.com) — Jambotron деплоит без .env
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:19:32 +01:00
Liosha84 0eb5348e4b Merge pull request 'Защита от перебора паролей: SMTP, IMAP, веб — лимит по IP и по ящику' (#14) from feat/login-guard into master
continuous-integration/drone/push Build is passing
Reviewed-on: #14
2026-11-06 18:44:15 +01:00
Liosha84andClaude Opus 5.5 b19e9682a8 Защита от перебора паролей: SMTP, IMAP, веб — лимит по IP и по ящику
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>
2026-11-06 18:32:33 +01:00
Liosha84 8f3ad77b69 Merge pull request 'Настоящая почта: docker-compose.public.yml (MX, DKIM, Let's Encrypt) + перечитка сертификата' (#13) from feat/public-mail into master
continuous-integration/drone/push Build is passing
Reviewed-on: #13
2026-11-06 13:12:10 +01:00
Liosha84 620aae179b Merge pull request 'Docker-образ в реестре Gitea (как у htop-web) и docker-compose.image.yml' (#12) from feat/docker-image into master
continuous-integration/drone/push Build is passing
2026-11-06 11:53:33 +01:00
16 changed files with 883 additions and 37 deletions
+18 -1
View File
@@ -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` | | `DKIM_DOMAIN`, `DKIM_SELECTOR`, `DKIM_KEYS_DIR` | DKIM | `localhost`, `mail`, `./dkim-keys` |
| `SPF_ENABLED`, `DMARC_ENABLED` | проверки входящих | `true` | | `SPF_ENABLED`, `DMARC_ENABLED` | проверки входящих | `true` |
| `MAIL_CORS_ORIGINS` | другие источники для веб-клиента (только разработка) | — | | `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` без перебора писем. - [ ] IMAP: проверить с Thunderbird на настоящем адресе; вложенные папки, CONDSTORE/QRESYNC, поиск по `BODY`/`TEXT` без перебора писем.
- [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново. - [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново.
- [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма. - [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма.
- [ ] Защита регистрации и входа от перебора (лимит попыток, капча). - [x] Защита входа от перебора: SMTP, IMAP, веб (лимит по IP и по ящику, см. «Защита от перебора паролей»).
- [ ] Защита регистрации от массового заведения ящиков (лимит, капча) — пока регистрация на публичной почте закрыта.
- [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя). - [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя).
- [ ] Настоящая почта (75.6): `docker-compose.public.yml` готов, первый запуск — `mail.jambocheck.com` на публичном адресе Horizon; адрес «прогревать» несколько недель, письма пользователям Jambotron пока через Resend (75.5). - [ ] Настоящая почта (75.6): `docker-compose.public.yml` готов, первый запуск — `mail.jambocheck.com` на публичном адресе Horizon; адрес «прогревать» несколько недель, письма пользователям Jambotron пока через Resend (75.5).
@@ -1,5 +1,6 @@
package com.mailserver.config; package com.mailserver.config;
import com.mailserver.security.LoginGuard;
import com.mailserver.imap.DbImapStore; import com.mailserver.imap.DbImapStore;
import com.mailserver.imap.ImapServer; import com.mailserver.imap.ImapServer;
import com.mailserver.imap.ImapStore; import com.mailserver.imap.ImapStore;
@@ -32,7 +33,7 @@ public class ImapConfig {
@Bean(initMethod = "start", destroyMethod = "stop") @Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnProperty(name = "mailserver.imap.enabled", havingValue = "true", matchIfMissing = true) @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.host:0.0.0.0}") String host,
@Value("${mailserver.imap.port:3143}") int port, @Value("${mailserver.imap.port:3143}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname, @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-timeout:30m}") Duration idleTimeout,
@Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) { @Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) {
return new ImapServer(new ImapServer.Settings(host, port, hostname, authWithoutTls, tls.context(), false, 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") @Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnExpression("'${mailserver.imap.enabled:true}' == 'true' and ${mailserver.imaps.port:0} > 0 and ${mailserver.tls.enabled:true}") @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.imap.host:0.0.0.0}") String host,
@Value("${mailserver.imaps.port}") int port, @Value("${mailserver.imaps.port}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname, @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-timeout:30m}") Duration idleTimeout,
@Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) { @Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) {
return new ImapServer(new ImapServer.Settings(host, port, hostname, false, tls.context(), true, 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);
} }
} }
@@ -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<String> 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;
}
}
@@ -1,5 +1,6 @@
package com.mailserver.config; package com.mailserver.config;
import com.mailserver.security.LoginGuard;
import com.mailserver.delivery.SmtpDeliveryClient; import com.mailserver.delivery.SmtpDeliveryClient;
import com.mailserver.smtp.MailRouter; import com.mailserver.smtp.MailRouter;
import com.mailserver.smtp.SmtpServer; import com.mailserver.smtp.SmtpServer;
@@ -39,7 +40,7 @@ public class MailServerConfig {
@Bean(initMethod = "start", destroyMethod = "stop") @Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnProperty(name = "mailserver.smtp.enabled", havingValue = "true", matchIfMissing = true) @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.host:0.0.0.0}") String host,
@Value("${mailserver.smtp.port:3025}") int port, @Value("${mailserver.smtp.port:3025}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname, @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.auth-without-tls:false}") boolean authWithoutTls,
@Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) { @Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) {
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, authWithoutTls, 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. */ /** Implicit TLS (RFC 8314, usually port 465) — only when mailserver.smtps.port is set and TLS is on. */
@Bean(initMethod = "start", destroyMethod = "stop") @Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnExpression("${mailserver.smtps.port:0} > 0 and ${mailserver.tls.enabled:true}") @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.smtp.host:0.0.0.0}") String host,
@Value("${mailserver.smtps.port}") int port, @Value("${mailserver.smtps.port}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname, @Value("${mailserver.smtp.hostname:localhost}") String hostname,
@Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes, @Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes,
@Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) { @Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) {
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, false, idleTimeout, return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, false, idleTimeout,
tls.context(), true), router); tls.context(), true), router, guard);
} }
/** /**
@@ -10,11 +10,16 @@ import com.mailserver.dto.ProfileUpdateRequest;
import com.mailserver.dto.RegisterRequest; import com.mailserver.dto.RegisterRequest;
import com.mailserver.service.MailService; import com.mailserver.service.MailService;
import com.mailserver.service.UserService; import com.mailserver.service.UserService;
import com.mailserver.security.LoginGuard;
import com.mailserver.util.JwtUtils; import com.mailserver.util.JwtUtils;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; 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 org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -34,6 +41,10 @@ import java.util.Set;
@RequestMapping("/api") @RequestMapping("/api")
public class AuthenticationController { 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). */ /** Service addresses nobody can register (RFC 2142 and ours). */
static final Set<String> RESERVED = Set.of("postmaster", "abuse", "mailer-daemon", "hostmaster", "webmaster", static final Set<String> RESERVED = Set.of("postmaster", "abuse", "mailer-daemon", "hostmaster", "webmaster",
"root", "admin", "noreply", "no-reply", "security", "support"); "root", "admin", "noreply", "no-reply", "security", "support");
@@ -43,9 +54,12 @@ public class AuthenticationController {
private final JwtUtils jwtUtils; private final JwtUtils jwtUtils;
private final MailDomains domains; private final MailDomains domains;
private final boolean registrationEnabled; private final boolean registrationEnabled;
private final LoginGuard guard;
public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains, 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.userService = userService;
this.mailService = mailService; this.mailService = mailService;
this.jwtUtils = jwtUtils; this.jwtUtils = jwtUtils;
@@ -60,23 +74,34 @@ public class AuthenticationController {
} }
@PostMapping("/login") @PostMapping("/login")
public Mono<ResponseEntity<?>> login(@Valid @RequestBody Mono<LoginRequest> requestMono) { public Mono<ResponseEntity<?>> login(@Valid @RequestBody Mono<LoginRequest> requestMono, ServerHttpRequest http) {
return requestMono.flatMap(request -> String ip = clientIp(http);
userService.findByEmail(request.getEmail().trim().toLowerCase(Locale.ROOT)) return requestMono.flatMap(request -> {
.switchIfEmpty(Mono.error(new IllegalArgumentException("Invalid credentials"))) 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 -> { .flatMap(user -> {
if (user == NO_USER) {
guard.failure(ip, email);
log.warn("WEB {}: authentication failed for {}", ip, email);
Mono<ResponseEntity<?>> 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()) { if (!user.isActive()) {
return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated"))); 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))); 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). */ /** 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") @PutMapping("/me/password")
public Mono<Map<String, String>> changePassword(Mono<Authentication> auth, @Valid @RequestBody Mono<PasswordChangeRequest> body) { public Mono<Map<String, String>> changePassword(Mono<Authentication> auth, @Valid @RequestBody Mono<PasswordChangeRequest> body,
ServerHttpRequest http) {
String ip = clientIp(http);
return user(auth).zipWith(body) 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")); .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<ProfileResponse> profile(User user) { private Mono<ProfileResponse> profile(User user) {
return mailService.usedBytes(user.getId()) return mailService.usedBytes(user.getId())
.map(used -> new ProfileResponse(user.getEmail(), user.getDisplayName(), used, .map(used -> new ProfileResponse(user.getEmail(), user.getDisplayName(), used,
@@ -1,5 +1,6 @@
package com.mailserver.imap; package com.mailserver.imap;
import com.mailserver.security.LoginGuard;
import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled; import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelHandlerContext;
@@ -67,11 +68,17 @@ public class ImapServer {
private final Settings settings; private final Settings settings;
private final ImapStore store; private final ImapStore store;
private final LoginGuard guard;
private DisposableServer server; private DisposableServer server;
public ImapServer(Settings settings, ImapStore store) { public ImapServer(Settings settings, ImapStore store) {
this(settings, store, LoginGuard.disabled());
}
public ImapServer(Settings settings, ImapStore store, LoginGuard guard) {
this.settings = settings; this.settings = settings;
this.store = store; this.store = store;
this.guard = guard;
} }
public void start() { public void start() {
@@ -98,7 +105,7 @@ public class ImapServer {
}); });
ImapSession session = new ImapSession( ImapSession session = new ImapSession(
new ImapSession.Settings(settings.hostname(), settings.tls() != null, settings.authWithoutTls()), new ImapSession.Settings(settings.hostname(), settings.tls() != null, settings.authWithoutTls()),
store, remoteIp[0], settings.implicitTls()); store, guard, remoteIp[0], settings.implicitTls());
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Sinks.Empty<Void>[] idleDone = new Sinks.Empty[1]; Sinks.Empty<Void>[] idleDone = new Sinks.Empty[1];
@@ -135,7 +142,9 @@ public class ImapServer {
log.debug("IMAP {}: connection closed: {}", session.remoteIp, e.toString()); log.debug("IMAP {}: connection closed: {}", session.remoteIp, e.toString());
return Mono.just(ImapSession.line("* BYE " + e.getMessage())); 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) return Flux.concat(Mono.just(session.greeting()), responses)
.concatMap(response -> response == ImapSession.STARTTLS_READY .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"); 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<byte[]> blocking(java.util.concurrent.Callable<List<byte[]>> work) { private static Flux<byte[]> blocking(java.util.concurrent.Callable<List<byte[]>> work) {
return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic()).flatMapIterable(list -> list); return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic()).flatMapIterable(list -> list);
} }
@@ -1,5 +1,6 @@
package com.mailserver.imap; package com.mailserver.imap;
import com.mailserver.security.LoginGuard;
import com.mailserver.service.LocalDelivery; import com.mailserver.service.LocalDelivery;
import jakarta.mail.Session; import jakarta.mail.Session;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
@@ -9,6 +10,7 @@ import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.ZoneOffset; import java.time.ZoneOffset;
@@ -39,6 +41,8 @@ final class ImapSession {
private static final Logger log = LoggerFactory.getLogger(ImapSession.class); private static final Logger log = LoggerFactory.getLogger(ImapSession.class);
private static final int MAX_BAD_COMMANDS = 30; 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 int SOURCE_CACHE = 16;
private static final DateTimeFormatter INTERNAL_DATE = private static final DateTimeFormatter INTERNAL_DATE =
DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm:ss Z", Locale.ENGLISH); DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm:ss Z", Locale.ENGLISH);
@@ -53,6 +57,7 @@ final class ImapSession {
private final Settings settings; private final Settings settings;
private final ImapStore store; private final ImapStore store;
private final LoginGuard guard;
final String remoteIp; final String remoteIp;
boolean tls; boolean tls;
@@ -78,8 +83,13 @@ final class ImapSession {
volatile String idleTag; volatile String idleTag;
ImapSession(Settings settings, ImapStore store, String remoteIp, boolean tls) { 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.settings = settings;
this.store = store; this.store = store;
this.guard = guard;
this.remoteIp = remoteIp; this.remoteIp = remoteIp;
this.tls = tls; this.tls = tls;
} }
@@ -254,7 +264,7 @@ final class ImapSession {
out.add(line(tag + " NO [PRIVACYREQUIRED] Use STARTTLS first")); out.add(line(tag + " NO [PRIVACYREQUIRED] Use STARTTLS first"));
return; return;
} }
loggedIn(tag, store.authenticate(user, password), user, out); checkPassword(tag, user, password, out);
} }
private void authenticate(String tag, ImapReader in, List<byte[]> out) { private void authenticate(String tag, ImapReader in, List<byte[]> out) {
@@ -292,13 +302,44 @@ final class ImapSession {
bad(tag, "Bad PLAIN response", out); bad(tag, "Bad PLAIN response", out);
return; 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<byte[]> 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<byte[]> out) { private void loggedIn(String tag, ImapStore.Account result, String user, List<byte[]> out) {
if (result == null) { if (result == null) {
log.warn("IMAP {}: authentication failed for {}", remoteIp, user); log.warn("IMAP {}: authentication failed for {}", remoteIp, user);
if (++badCommands > 5) { if (++badCommands >= MAX_AUTH_FAILURES) {
out.add(line("* BYE Too many failed logins")); out.add(line("* BYE Too many failed logins"));
closing = true; closing = true;
} }
@@ -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.
* <ul>
* <li><b>By IP:</b> {@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}.</li>
* <li><b>By mailbox:</b> {@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.</li>
* </ul>
* 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<String> 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<Instant> 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<String, Counter> byIp = new ConcurrentHashMap<>();
private final Map<String, Counter> byAccount = new ConcurrentHashMap<>();
/** account → IPs that logged in to it successfully, with the time of the last success. */
private final Map<String, Map<String, Instant>> 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<String, Instant> 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<String, Counter> 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<String, Instant> 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";
}
}
@@ -1,5 +1,6 @@
package com.mailserver.smtp; package com.mailserver.smtp;
import com.mailserver.security.LoginGuard;
import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufUtil; import io.netty.buffer.ByteBufUtil;
import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelHandlerContext;
@@ -54,6 +55,8 @@ public class SmtpServer {
static final int MAX_LINE = 64 * 1024; static final int MAX_LINE = 64 * 1024;
static final int MAX_RECIPIENTS = 100; static final int MAX_RECIPIENTS = 100;
private static final int MAX_BAD_COMMANDS = 20; 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 // 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"); 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 Settings settings;
private final SmtpBackend backend; private final SmtpBackend backend;
private final LoginGuard guard;
private DisposableServer server; private DisposableServer server;
public SmtpServer(Settings settings, SmtpBackend backend) { public SmtpServer(Settings settings, SmtpBackend backend) {
this(settings, backend, LoginGuard.disabled());
}
public SmtpServer(Settings settings, SmtpBackend backend, LoginGuard guard) {
this.settings = settings; this.settings = settings;
this.backend = backend; this.backend = backend;
this.guard = guard;
} }
public void start() { public void start() {
@@ -183,6 +192,7 @@ public class SmtpServer {
final ByteArrayOutputStream data = new ByteArrayOutputStream(); final ByteArrayOutputStream data = new ByteArrayOutputStream();
boolean dataTooBig; boolean dataTooBig;
int badCommands; int badCommands;
int authFailures;
volatile boolean closing; volatile boolean closing;
Session(String remoteIp, boolean tls) { Session(String remoteIp, boolean tls) {
@@ -356,16 +366,32 @@ public class SmtpServer {
private Mono<String> checkPassword(String user, String password) { private Mono<String> checkPassword(String user, String password) {
String username = user.trim().toLowerCase(Locale.ROOT); 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)) return blocking(() -> backend.authenticate(username, password))
.map(ok -> { .flatMap(ok -> {
if (ok) { if (ok) {
guard.success(remoteIp, username);
authenticatedUser = username; authenticatedUser = username;
log.info("SMTP {}: authenticated as {}", remoteIp, 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); log.warn("SMTP {}: authentication failed for {}", remoteIp, username);
badCommands++; 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);
}); });
} }
@@ -83,3 +83,15 @@ management.endpoints.web.exposure.include=health
management.endpoint.health.show-details=never management.endpoint.health.show-details=never
logging.level.com.mailserver=INFO 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:}
@@ -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();
}
}
}
@@ -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"));
}
}
@@ -14,6 +14,7 @@ import jakarta.mail.internet.MimeMessage;
import jakarta.mail.search.SubjectTerm; import jakarta.mail.search.SubjectTerm;
import org.eclipse.angus.mail.imap.AppendUID; import org.eclipse.angus.mail.imap.AppendUID;
import org.eclipse.angus.mail.imap.IMAPFolder; import org.eclipse.angus.mail.imap.IMAPFolder;
import com.mailserver.security.LoginGuard;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; 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 @Test
void loginIsRefusedInPlainTextAndTheGreetingSaysSo() throws Exception { void loginIsRefusedInPlainTextAndTheGreetingSaysSo() throws Exception {
try (Client client = new Client(server.port())) { try (Client client = new Client(server.port())) {
@@ -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");
}
}
@@ -5,6 +5,7 @@ import jakarta.mail.Session;
import jakarta.mail.Transport; import jakarta.mail.Transport;
import jakarta.mail.internet.InternetAddress; import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
import com.mailserver.security.LoginGuard;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -158,6 +159,34 @@ class SmtpServerTest {
assertThat(backend.messages.get(0)).contains("with ESMTPA;"); 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 @Test
void authLoginWorksToo() throws Exception { void authLoginWorksToo() throws Exception {
try (Client client = new Client(server.port())) { try (Client client = new Client(server.port())) {
+7 -5
View File
@@ -21,6 +21,8 @@
# #
# Mailbox admin@<domain>: its password is in the "mail" log on the first start and in /app/data/initial-passwords.txt. # Mailbox admin@<domain>: its password is in the "mail" log on the first start and in /app/data/initial-passwords.txt.
# Registration is closed: new mailboxes only by the admin. No catch-all. # Registration is closed: new mailboxes only by the admin. No catch-all.
# Defaults are ours (mail.jambocheck.com / jambocheck.com) — Jambotron deploys without a .env; another domain:
# MAIL_HOSTNAME, MAIL_DOMAINS, ACME_EMAIL in .env next to this file.
name: reactive-mail-public name: reactive-mail-public
services: services:
@@ -71,8 +73,8 @@ services:
[ -s /tls/cert.pem ] && sleep 43200 || sleep 3600 [ -s /tls/cert.pem ] && sleep 43200 || sleep 3600
done done
environment: environment:
MAIL_HOSTNAME: ${MAIL_HOSTNAME:?MAIL_HOSTNAME, e.g. mail.jambocheck.com} MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.jambocheck.com}
ACME_EMAIL: ${ACME_EMAIL:-postmaster@${MAIL_DOMAINS}} ACME_EMAIL: ${ACME_EMAIL:-postmaster@jambocheck.com}
LEGO_LOG_FORMAT: text LEGO_LOG_FORMAT: text
volumes: volumes:
- lego:/var/lib/lego - lego:/var/lib/lego
@@ -124,13 +126,13 @@ services:
environment: environment:
DB_HOST: db DB_HOST: db
MAILSERVER_SECRETS_DIR: /run/mail-secrets MAILSERVER_SECRETS_DIR: /run/mail-secrets
MAIL_DOMAINS: ${MAIL_DOMAINS:?MAIL_DOMAINS, e.g. jambocheck.com} MAIL_DOMAINS: ${MAIL_DOMAINS:-jambocheck.com}
MAIL_HOSTNAME: ${MAIL_HOSTNAME:?MAIL_HOSTNAME, e.g. mail.jambocheck.com} MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.jambocheck.com}
MAIL_TLS_CERT: /tls/cert.pem MAIL_TLS_CERT: /tls/cert.pem
MAIL_TLS_KEY: /tls/key.pem MAIL_TLS_KEY: /tls/key.pem
SMTPS_PORT: "3465" SMTPS_PORT: "3465"
IMAPS_PORT: "3993" IMAPS_PORT: "3993"
DKIM_DOMAIN: ${MAIL_DOMAINS} DKIM_DOMAIN: ${MAIL_DOMAINS:-jambocheck.com}
DKIM_SELECTOR: mail DKIM_SELECTOR: mail
DKIM_KEYS_DIR: /app/data/dkim-keys DKIM_KEYS_DIR: /app/data/dkim-keys
MAIL_CATCH_ALL: "" MAIL_CATCH_ALL: ""