Add client challenges for prekey and message rate limiters

This commit is contained in:
Jon Chambers
2021-05-11 17:21:32 -04:00
committed by GitHub
parent 5752853bba
commit 46110d4d65
46 changed files with 2289 additions and 255 deletions

View File

@@ -17,43 +17,45 @@ import org.whispersystems.textsecuregcm.redis.AbstractRedisClusterTest;
public class CardinalityRateLimiterTest extends AbstractRedisClusterTest {
@Before
public void setUp() throws Exception {
super.setUp();
}
@Before
public void setUp() throws Exception {
super.setUp();
}
@After
public void tearDown() throws Exception {
super.tearDown();
}
@After
public void tearDown() throws Exception {
super.tearDown();
}
@Test
public void testValidate() {
final int maxCardinality = 10;
final CardinalityRateLimiter rateLimiter = new CardinalityRateLimiter(getRedisCluster(), "test", Duration.ofDays(1), Duration.ofDays(1), maxCardinality);
@Test
public void testValidate() {
final int maxCardinality = 10;
final CardinalityRateLimiter rateLimiter =
new CardinalityRateLimiter(getRedisCluster(), "test", Duration.ofDays(1), maxCardinality);
final String source = "+18005551234";
int validatedAttempts = 0;
int blockedAttempts = 0;
for (int i = 0; i < maxCardinality * 2; i++) {
try {
rateLimiter.validate(source, String.valueOf(i));
validatedAttempts++;
} catch (final RateLimitExceededException e) {
blockedAttempts++;
}
}
assertTrue(validatedAttempts >= maxCardinality);
assertTrue(blockedAttempts > 0);
final String secondSource = "+18005554321";
final String source = "+18005551234";
int validatedAttempts = 0;
int blockedAttempts = 0;
for (int i = 0; i < maxCardinality * 2; i++) {
try {
rateLimiter.validate(secondSource, "test");
rateLimiter.validate(source, String.valueOf(i), rateLimiter.getDefaultMaxCardinality());
validatedAttempts++;
} catch (final RateLimitExceededException e) {
fail("New source should not trigger a rate limit exception on first attempted validation");
blockedAttempts++;
}
}
assertTrue(validatedAttempts >= maxCardinality);
assertTrue(blockedAttempts > 0);
final String secondSource = "+18005554321";
try {
rateLimiter.validate(secondSource, "test", rateLimiter.getDefaultMaxCardinality());
} catch (final RateLimitExceededException e) {
fail("New source should not trigger a rate limit exception on first attempted validation");
}
}
}

View File

@@ -0,0 +1,66 @@
package org.whispersystems.textsecuregcm.limits;
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRateLimitChallengeConfiguration;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
class PreKeyRateLimiterTest {
private Account account;
private PreKeyRateLimiter preKeyRateLimiter;
private DynamicRateLimitChallengeConfiguration rateLimitChallengeConfiguration;
private RateLimiter dailyPreKeyLimiter;
@BeforeEach
void setup() {
final RateLimiters rateLimiters = mock(RateLimiters.class);
dailyPreKeyLimiter = mock(RateLimiter.class);
when(rateLimiters.getDailyPreKeysLimiter()).thenReturn(dailyPreKeyLimiter);
final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
rateLimitChallengeConfiguration = mock(DynamicRateLimitChallengeConfiguration.class);
final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
when(dynamicConfiguration.getRateLimitChallengeConfiguration()).thenReturn(rateLimitChallengeConfiguration);
preKeyRateLimiter = new PreKeyRateLimiter(rateLimiters, dynamicConfigurationManager, mock(RateLimitResetMetricsManager.class));
account = mock(Account.class);
when(account.getNumber()).thenReturn("+18005551111");
when(account.getUuid()).thenReturn(UUID.randomUUID());
}
@Test
void enforcementConfiguration() throws RateLimitExceededException {
doThrow(RateLimitExceededException.class)
.when(dailyPreKeyLimiter).validate(any());
when(rateLimitChallengeConfiguration.isPreKeyLimitEnforced()).thenReturn(false);
preKeyRateLimiter.validate(account);
when(rateLimitChallengeConfiguration.isPreKeyLimitEnforced()).thenReturn(true);
assertThrows(RateLimitExceededException.class, () -> preKeyRateLimiter.validate(account));
when(rateLimitChallengeConfiguration.isPreKeyLimitEnforced()).thenReturn(false);
preKeyRateLimiter.validate(account);
}
}

View File

@@ -0,0 +1,190 @@
package org.whispersystems.textsecuregcm.limits;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyZeroInteractions;
import static org.mockito.Mockito.when;
import com.vdurmont.semver4j.Semver;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRateLimitChallengeConfiguration;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
class RateLimitChallengeManagerTest {
private PushChallengeManager pushChallengeManager;
private RecaptchaClient recaptchaClient;
private PreKeyRateLimiter preKeyRateLimiter;
private UnsealedSenderRateLimiter unsealedSenderRateLimiter;
private DynamicRateLimitChallengeConfiguration rateLimitChallengeConfiguration;
private RateLimiters rateLimiters;
private RateLimitChallengeManager rateLimitChallengeManager;
@BeforeEach
void setUp() {
pushChallengeManager = mock(PushChallengeManager.class);
recaptchaClient = mock(RecaptchaClient.class);
preKeyRateLimiter = mock(PreKeyRateLimiter.class);
unsealedSenderRateLimiter = mock(UnsealedSenderRateLimiter.class);
rateLimiters = mock(RateLimiters.class);
final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
rateLimitChallengeConfiguration = mock(DynamicRateLimitChallengeConfiguration.class);
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
when(dynamicConfiguration.getRateLimitChallengeConfiguration()).thenReturn(rateLimitChallengeConfiguration);
rateLimitChallengeManager = new RateLimitChallengeManager(
pushChallengeManager,
recaptchaClient,
preKeyRateLimiter,
unsealedSenderRateLimiter,
rateLimiters,
dynamicConfigurationManager);
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void answerPushChallenge(final boolean successfulChallenge) throws RateLimitExceededException {
final Account account = mock(Account.class);
when(pushChallengeManager.answerChallenge(eq(account), any())).thenReturn(successfulChallenge);
when(rateLimiters.getPushChallengeAttemptLimiter()).thenReturn(mock(RateLimiter.class));
when(rateLimiters.getPushChallengeSuccessLimiter()).thenReturn(mock(RateLimiter.class));
when(rateLimiters.getRateLimitResetLimiter()).thenReturn(mock(RateLimiter.class));
rateLimitChallengeManager.answerPushChallenge(account, "challenge");
if (successfulChallenge) {
verify(preKeyRateLimiter).handleRateLimitReset(account);
verify(unsealedSenderRateLimiter).handleRateLimitReset(account);
} else {
verifyZeroInteractions(preKeyRateLimiter);
verifyZeroInteractions(unsealedSenderRateLimiter);
}
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void answerRecaptchaChallenge(final boolean successfulChallenge) throws RateLimitExceededException {
final Account account = mock(Account.class);
when(recaptchaClient.verify(any(), any())).thenReturn(successfulChallenge);
when(rateLimiters.getRecaptchaChallengeAttemptLimiter()).thenReturn(mock(RateLimiter.class));
when(rateLimiters.getRecaptchaChallengeSuccessLimiter()).thenReturn(mock(RateLimiter.class));
when(rateLimiters.getRateLimitResetLimiter()).thenReturn(mock(RateLimiter.class));
rateLimitChallengeManager.answerRecaptchaChallenge(account, "captcha", "10.0.0.1");
if (successfulChallenge) {
verify(preKeyRateLimiter).handleRateLimitReset(account);
verify(unsealedSenderRateLimiter).handleRateLimitReset(account);
} else {
verifyZeroInteractions(preKeyRateLimiter);
verifyZeroInteractions(unsealedSenderRateLimiter);
}
}
@ParameterizedTest
@MethodSource
void shouldIssueRateLimitChallenge(final String userAgent, final boolean expectIssueChallenge) {
when(rateLimitChallengeConfiguration.getMinimumSupportedVersion(any())).thenReturn(Optional.empty());
when(rateLimitChallengeConfiguration.getMinimumSupportedVersion(ClientPlatform.ANDROID))
.thenReturn(Optional.of(new Semver("5.6.0")));
when(rateLimitChallengeConfiguration.getMinimumSupportedVersion(ClientPlatform.DESKTOP))
.thenReturn(Optional.of(new Semver("5.0.0-beta.2")));
assertEquals(expectIssueChallenge, rateLimitChallengeManager.shouldIssueRateLimitChallenge(userAgent));
}
private static Stream<Arguments> shouldIssueRateLimitChallenge() {
return Stream.of(
Arguments.of("Signal-Android/5.1.2 Android/30", false),
Arguments.of("Signal-Android/5.6.0 Android/30", true),
Arguments.of("Signal-Android/5.11.1 Android/30", true),
Arguments.of("Signal-Desktop/5.0.0-beta.3 macOS/11", true),
Arguments.of("Signal-Desktop/5.0.0-beta.1 Windows/3.1", false),
Arguments.of("Signal-Desktop/5.2.0 Debian/11", true),
Arguments.of("Signal-iOS/5.1.2 iOS/12.2", false),
Arguments.of("anything-else", false)
);
}
@ParameterizedTest
@MethodSource
void getChallengeOptions(final boolean captchaAttemptPermitted,
final boolean captchaSuccessPermitted,
final boolean pushAttemptPermitted,
final boolean pushSuccessPermitted,
final boolean expectCaptcha,
final boolean expectPushChallenge) {
final RateLimiter recaptchaChallengeAttemptLimiter = mock(RateLimiter.class);
final RateLimiter recaptchaChallengeSuccessLimiter = mock(RateLimiter.class);
final RateLimiter pushChallengeAttemptLimiter = mock(RateLimiter.class);
final RateLimiter pushChallengeSuccessLimiter = mock(RateLimiter.class);
when(rateLimiters.getRecaptchaChallengeAttemptLimiter()).thenReturn(recaptchaChallengeAttemptLimiter);
when(rateLimiters.getRecaptchaChallengeSuccessLimiter()).thenReturn(recaptchaChallengeSuccessLimiter);
when(rateLimiters.getPushChallengeAttemptLimiter()).thenReturn(pushChallengeAttemptLimiter);
when(rateLimiters.getPushChallengeSuccessLimiter()).thenReturn(pushChallengeSuccessLimiter);
when(recaptchaChallengeAttemptLimiter.hasAvailablePermits(any(), anyInt())).thenReturn(captchaAttemptPermitted);
when(recaptchaChallengeSuccessLimiter.hasAvailablePermits(any(), anyInt())).thenReturn(captchaSuccessPermitted);
when(pushChallengeAttemptLimiter.hasAvailablePermits(any(), anyInt())).thenReturn(pushAttemptPermitted);
when(pushChallengeSuccessLimiter.hasAvailablePermits(any(), anyInt())).thenReturn(pushSuccessPermitted);
final int expectedLength = (expectCaptcha ? 1 : 0) + (expectPushChallenge ? 1 : 0);
final List<String> options = rateLimitChallengeManager.getChallengeOptions(mock(Account.class));
assertEquals(expectedLength, options.size());
if (expectCaptcha) {
assertTrue(options.contains(RateLimitChallengeManager.OPTION_RECAPTCHA));
}
if (expectPushChallenge) {
assertTrue(options.contains(RateLimitChallengeManager.OPTION_PUSH_CHALLENGE));
}
}
private static Stream<Arguments> getChallengeOptions() {
return Stream.of(
Arguments.of(false, false, false, false, false, false),
Arguments.of(false, false, false, true, false, false),
Arguments.of(false, false, true, false, false, false),
Arguments.of(false, false, true, true, false, true),
Arguments.of(false, true, false, false, false, false),
Arguments.of(false, true, false, true, false, false),
Arguments.of(false, true, true, false, false, false),
Arguments.of(false, true, true, true, false, true),
Arguments.of(true, false, false, false, false, false),
Arguments.of(true, false, false, true, false, false),
Arguments.of(true, false, true, false, false, false),
Arguments.of(true, false, true, true, false, true),
Arguments.of(true, true, false, false, true, false),
Arguments.of(true, true, false, true, true, false),
Arguments.of(true, true, true, false, true, false),
Arguments.of(true, true, true, true, true, true)
);
}
}

View File

@@ -0,0 +1,58 @@
package org.whispersystems.textsecuregcm.limits;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import io.dropwizard.util.Duration;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import java.util.UUID;
import org.junit.Before;
import org.junit.Test;
import org.whispersystems.textsecuregcm.redis.AbstractRedisClusterTest;
import org.whispersystems.textsecuregcm.storage.Account;
public class RateLimitResetMetricsManagerTest extends AbstractRedisClusterTest {
private RateLimitResetMetricsManager metricsManager;
private SimpleMeterRegistry meterRegistry;
@Before
@Override
public void setUp() throws Exception {
super.setUp();
meterRegistry = new SimpleMeterRegistry();
metricsManager = new RateLimitResetMetricsManager(getRedisCluster(), meterRegistry);
}
@Test
public void testRecordMetrics() {
final Account firstAccount = mock(Account.class);
when(firstAccount.getUuid()).thenReturn(UUID.randomUUID());
final Account secondAccount = mock(Account.class);
when(secondAccount.getUuid()).thenReturn(UUID.randomUUID());
metricsManager.recordMetrics(firstAccount, true, "counter", "enforced", "total", Duration.hours(1).toSeconds());
metricsManager.recordMetrics(firstAccount, true, "counter", "enforced", "total", Duration.hours(1).toSeconds());
metricsManager.recordMetrics(secondAccount, false, "counter", "unenforced", "total", Duration.hours(1).toSeconds());
final double counterTotal = meterRegistry.get("counter").counters().stream()
.map(Counter::count)
.reduce(Double::sum)
.orElseThrow();
assertEquals(3, counterTotal, 0.0);
final long enforcedCount = getRedisCluster().withCluster(conn -> conn.sync().pfcount("enforced"));
assertEquals(1L, enforcedCount);
final long unenforcedCount = getRedisCluster().withCluster(conn -> conn.sync().pfcount("unenforced"));
assertEquals(1L, unenforcedCount);
final long total = getRedisCluster().withCluster(conn -> conn.sync().pfcount("total"));
assertEquals(2L, total);
}
}

View File

@@ -0,0 +1,118 @@
/*
* Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.limits;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.util.UUID;
import org.junit.Before;
import org.junit.Test;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicMessageRateConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRateLimitChallengeConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRateLimitsConfiguration;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.redis.AbstractRedisClusterTest;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
public class UnsealedSenderRateLimiterTest extends AbstractRedisClusterTest {
private Account sender;
private Account firstDestination;
private Account secondDestination;
private UnsealedSenderRateLimiter unsealedSenderRateLimiter;
private DynamicRateLimitChallengeConfiguration rateLimitChallengeConfiguration;
@Before
@Override
public void setUp() throws Exception {
super.setUp();
final RateLimiters rateLimiters = mock(RateLimiters.class);
final CardinalityRateLimiter cardinalityRateLimiter =
new CardinalityRateLimiter(getRedisCluster(), "test", Duration.ofDays(1), 1);
when(rateLimiters.getUnsealedSenderCardinalityLimiter()).thenReturn(cardinalityRateLimiter);
when(rateLimiters.getRateLimitResetLimiter()).thenReturn(mock(RateLimiter.class));
final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
final DynamicRateLimitsConfiguration rateLimitsConfiguration = mock(DynamicRateLimitsConfiguration.class);
rateLimitChallengeConfiguration = mock(DynamicRateLimitChallengeConfiguration.class);
final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
when(dynamicConfiguration.getLimits()).thenReturn(rateLimitsConfiguration);
when(rateLimitsConfiguration.getUnsealedSenderDefaultCardinalityLimit()).thenReturn(1);
when(rateLimitsConfiguration.getUnsealedSenderPermitIncrement()).thenReturn(1);
when(dynamicConfiguration.getRateLimitChallengeConfiguration()).thenReturn(rateLimitChallengeConfiguration);
when(rateLimitChallengeConfiguration.isUnsealedSenderLimitEnforced()).thenReturn(true);
unsealedSenderRateLimiter = new UnsealedSenderRateLimiter(rateLimiters, getRedisCluster(), dynamicConfigurationManager,
mock(RateLimitResetMetricsManager.class));
sender = mock(Account.class);
when(sender.getNumber()).thenReturn("+18005551111");
when(sender.getUuid()).thenReturn(UUID.randomUUID());
firstDestination = mock(Account.class);
when(firstDestination.getNumber()).thenReturn("+18005552222");
when(firstDestination.getUuid()).thenReturn(UUID.randomUUID());
secondDestination = mock(Account.class);
when(secondDestination.getNumber()).thenReturn("+18005553333");
when(secondDestination.getUuid()).thenReturn(UUID.randomUUID());
}
@Test
public void validate() throws RateLimitExceededException {
unsealedSenderRateLimiter.validate(sender, firstDestination);
assertThrows(RateLimitExceededException.class, () -> unsealedSenderRateLimiter.validate(sender, secondDestination));
unsealedSenderRateLimiter.validate(sender, firstDestination);
}
@Test
public void handleRateLimitReset() throws RateLimitExceededException {
unsealedSenderRateLimiter.validate(sender, firstDestination);
assertThrows(RateLimitExceededException.class, () -> unsealedSenderRateLimiter.validate(sender, secondDestination));
unsealedSenderRateLimiter.handleRateLimitReset(sender);
unsealedSenderRateLimiter.validate(sender, firstDestination);
unsealedSenderRateLimiter.validate(sender, secondDestination);
}
@Test
public void enforcementConfiguration() throws RateLimitExceededException {
when(rateLimitChallengeConfiguration.isUnsealedSenderLimitEnforced()).thenReturn(false);
unsealedSenderRateLimiter.validate(sender, firstDestination);
unsealedSenderRateLimiter.validate(sender, secondDestination);
when(rateLimitChallengeConfiguration.isUnsealedSenderLimitEnforced()).thenReturn(true);
final Account thirdDestination = mock(Account.class);
when(thirdDestination.getNumber()).thenReturn("+18005554444");
when(thirdDestination.getUuid()).thenReturn(UUID.randomUUID());
assertThrows(RateLimitExceededException.class, () -> unsealedSenderRateLimiter.validate(sender, thirdDestination));
when(rateLimitChallengeConfiguration.isUnsealedSenderLimitEnforced()).thenReturn(false);
final Account fourthDestination = mock(Account.class);
when(fourthDestination.getNumber()).thenReturn("+18005555555");
when(fourthDestination.getUuid()).thenReturn(UUID.randomUUID());
unsealedSenderRateLimiter.validate(sender, fourthDestination);
}
}