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>
165 lines
5.8 KiB
Java
165 lines
5.8 KiB
Java
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");
|
|
}
|
|
}
|