Защита от перебора паролей: SMTP, IMAP, веб — лимит по IP и по ящику
continuous-integration/drone/pr Build is passing
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>
This commit is contained in:
@@ -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 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())) {
|
||||
|
||||
@@ -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.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())) {
|
||||
|
||||
Reference in New Issue
Block a user