Migrate challenge-issuing rate limiters to the abusive message filter

This commit is contained in:
Jon Chambers
2021-11-18 15:39:46 -05:00
committed by Jon Chambers
parent 9628f147f1
commit 14cff958e9
26 changed files with 311 additions and 982 deletions

View File

@@ -355,14 +355,13 @@ class DynamicConfigurationTest {
DynamicConfigurationManager.parseConfiguration(emptyConfigYaml, DynamicConfiguration.class).orElseThrow();
assertThat(emptyConfig.getRateLimitChallengeConfiguration().getClientSupportedVersions()).isEmpty();
assertThat(emptyConfig.getRateLimitChallengeConfiguration().isPreKeyLimitEnforced()).isFalse();
assertThat(emptyConfig.getRateLimitChallengeConfiguration().isUnsealedSenderLimitEnforced()).isFalse();
}
{
final String rateLimitChallengeConfig = """
rateLimitChallenge:
preKeyLimitEnforced: true
unsealedSenderLimitEnforced: true
clientSupportedVersions:
IOS: 5.1.0
ANDROID: 5.2.0
@@ -378,8 +377,7 @@ class DynamicConfigurationTest {
assertThat(clientSupportedVersions.get(ClientPlatform.IOS)).isEqualTo(new Semver("5.1.0"));
assertThat(clientSupportedVersions.get(ClientPlatform.ANDROID)).isEqualTo(new Semver("5.2.0"));
assertThat(clientSupportedVersions.get(ClientPlatform.DESKTOP)).isEqualTo(new Semver("5.0.0"));
assertThat(rateLimitChallengeConfiguration.isPreKeyLimitEnforced()).isTrue();
assertThat(rateLimitChallengeConfiguration.isUnsealedSenderLimitEnforced()).isFalse();
assertThat(rateLimitChallengeConfiguration.isUnsealedSenderLimitEnforced()).isTrue();
}
}

View File

@@ -18,7 +18,6 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.anyBoolean;
import static org.mockito.Mockito.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
@@ -35,7 +34,6 @@ import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
import java.time.Duration;
import java.util.Base64;
import java.util.Collection;
import java.util.HashSet;
@@ -58,7 +56,6 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
@@ -69,14 +66,10 @@ import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;
import org.whispersystems.textsecuregcm.entities.MismatchedDevices;
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity;
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;
import org.whispersystems.textsecuregcm.entities.RateLimitChallenge;
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
import org.whispersystems.textsecuregcm.entities.StaleDevices;
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.limits.UnsealedSenderRateLimiter;
import org.whispersystems.textsecuregcm.mappers.RateLimitChallengeExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.push.ApnFallbackManager;
import org.whispersystems.textsecuregcm.push.MessageSender;
@@ -113,9 +106,7 @@ class MessageControllerTest {
private static final MessagesManager messagesManager = mock(MessagesManager.class);
private static final RateLimiters rateLimiters = mock(RateLimiters.class);
private static final RateLimiter rateLimiter = mock(RateLimiter.class);
private static final UnsealedSenderRateLimiter unsealedSenderRateLimiter = mock(UnsealedSenderRateLimiter.class);
private static final ApnFallbackManager apnFallbackManager = mock(ApnFallbackManager.class);
private static final RateLimitChallengeManager rateLimitChallengeManager = mock(RateLimitChallengeManager.class);
private static final ReportMessageManager reportMessageManager = mock(ReportMessageManager.class);
private static final ExecutorService multiRecipientMessageExecutor = mock(ExecutorService.class);
@@ -126,11 +117,9 @@ class MessageControllerTest {
.addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(
ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class)))
.addProvider(RateLimitExceededExceptionMapper.class)
.addProvider(new RateLimitChallengeExceptionMapper(rateLimitChallengeManager))
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new MessageController(rateLimiters, messageSender, receiptSender, accountsManager,
messagesManager, unsealedSenderRateLimiter, apnFallbackManager,
rateLimitChallengeManager, reportMessageManager, multiRecipientMessageExecutor))
messagesManager, apnFallbackManager, reportMessageManager, multiRecipientMessageExecutor))
.build();
@BeforeEach
@@ -179,9 +168,7 @@ class MessageControllerTest {
messagesManager,
rateLimiters,
rateLimiter,
unsealedSenderRateLimiter,
apnFallbackManager,
rateLimitChallengeManager,
reportMessageManager
);
}
@@ -282,69 +269,6 @@ class MessageControllerTest {
assertThat("Bad request", response.getStatus(), is(equalTo(422)));
}
@ParameterizedTest
@CsvSource({"true, true, 413", "true, false, 428", "false, false, 200"})
void testUnsealedSenderCardinalityRateLimited(final boolean rateLimited, final boolean legacyClient,
final int expectedStatusCode) throws Exception {
if (rateLimited) {
doThrow(new RateLimitExceededException(Duration.ofHours(1)))
.when(unsealedSenderRateLimiter).validate(eq(AuthHelper.VALID_ACCOUNT), eq(internationalAccount));
when(rateLimitChallengeManager.isClientBelowMinimumVersion(anyString()))
.thenReturn(legacyClient);
}
Response response =
resources.getJerseyTest()
.target(String.format("/v1/messages/%s", INTERNATIONAL_UUID))
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.header("User-Agent", "Signal-Android/5.6.4 Android/30")
.put(Entity.entity(mapper.readValue(jsonFixture("fixtures/current_message_single_device.json"), IncomingMessageList.class),
MediaType.APPLICATION_JSON_TYPE));
if (rateLimited) {
assertThat("Error Response", response.getStatus(), is(equalTo(expectedStatusCode)));
} else {
assertThat("Good Response", response.getStatus(), is(equalTo(expectedStatusCode)));
}
verify(messageSender, rateLimited ? never() : times(1)).sendMessage(any(), any(), any(), anyBoolean());
}
@Test
void testRateLimitResetRequirement() throws Exception {
Duration retryAfter = Duration.ofMinutes(1);
doThrow(new RateLimitExceededException(retryAfter))
.when(unsealedSenderRateLimiter).validate(any(), any());
when(rateLimitChallengeManager.isClientBelowMinimumVersion("Signal-Android/5.1.2 Android/30")).thenReturn(false);
when(rateLimitChallengeManager.getChallengeOptions(AuthHelper.VALID_ACCOUNT))
.thenReturn(
List.of(RateLimitChallengeManager.OPTION_PUSH_CHALLENGE, RateLimitChallengeManager.OPTION_RECAPTCHA));
Response response =
resources.getJerseyTest()
.target(String.format("/v1/messages/%s", INTERNATIONAL_UUID))
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.header("User-Agent", "Signal-Android/5.1.2 Android/30")
.put(Entity.entity(mapper.readValue(jsonFixture("fixtures/current_message_single_device.json"), IncomingMessageList.class),
MediaType.APPLICATION_JSON_TYPE));
assertEquals(428, response.getStatus());
RateLimitChallenge rateLimitChallenge = response.readEntity(RateLimitChallenge.class);
assertFalse(rateLimitChallenge.getToken().isBlank());
assertFalse(rateLimitChallenge.getOptions().isEmpty());
assertTrue(rateLimitChallenge.getOptions().contains("recaptcha"));
assertTrue(rateLimitChallenge.getOptions().contains("pushChallenge"));
assertEquals(retryAfter.toSeconds(), Long.parseLong(response.getHeaderString("Retry-After")));
}
@Test
void testSingleDeviceCurrentUnidentified() throws Exception {
Response response =

View File

@@ -1,66 +0,0 @@
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 DynamicRateLimiters rateLimiters = mock(DynamicRateLimiters.class);
dailyPreKeyLimiter = mock(RateLimiter.class);
when(rateLimiters.getDailyPreKeysLimiter()).thenReturn(dailyPreKeyLimiter);
final DynamicConfigurationManager<DynamicConfiguration> 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(UUID.class));
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

@@ -1,41 +1,28 @@
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.verifyNoInteractions;
import static org.mockito.Mockito.when;
import com.vdurmont.semver4j.Semver;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
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.abuse.RateLimitChallengeListener;
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 DynamicRateLimiters rateLimiters;
private RateLimitChallengeListener rateLimitChallengeListener;
private RateLimitChallengeManager rateLimitChallengeManager;
@@ -43,24 +30,15 @@ class RateLimitChallengeManagerTest {
void setUp() {
pushChallengeManager = mock(PushChallengeManager.class);
recaptchaClient = mock(RecaptchaClient.class);
preKeyRateLimiter = mock(PreKeyRateLimiter.class);
unsealedSenderRateLimiter = mock(UnsealedSenderRateLimiter.class);
rateLimiters = mock(DynamicRateLimiters.class);
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
rateLimitChallengeConfiguration = mock(DynamicRateLimitChallengeConfiguration.class);
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
when(dynamicConfiguration.getRateLimitChallengeConfiguration()).thenReturn(rateLimitChallengeConfiguration);
rateLimitChallengeListener = mock(RateLimitChallengeListener.class);
rateLimitChallengeManager = new RateLimitChallengeManager(
pushChallengeManager,
recaptchaClient,
preKeyRateLimiter,
unsealedSenderRateLimiter,
rateLimiters,
dynamicConfigurationManager);
rateLimiters);
rateLimitChallengeManager.addListener(rateLimitChallengeListener);
}
@ParameterizedTest
@@ -78,11 +56,9 @@ class RateLimitChallengeManagerTest {
rateLimitChallengeManager.answerPushChallenge(account, "challenge");
if (successfulChallenge) {
verify(preKeyRateLimiter).handleRateLimitReset(account);
verify(unsealedSenderRateLimiter).handleRateLimitReset(account);
verify(rateLimitChallengeListener).handleRateLimitChallengeAnswered(account);
} else {
verifyNoInteractions(preKeyRateLimiter);
verifyNoInteractions(unsealedSenderRateLimiter);
verifyNoInteractions(rateLimitChallengeListener);
}
}
@@ -102,98 +78,9 @@ class RateLimitChallengeManagerTest {
rateLimitChallengeManager.answerRecaptchaChallenge(account, "captcha", "10.0.0.1");
if (successfulChallenge) {
verify(preKeyRateLimiter).handleRateLimitReset(account);
verify(unsealedSenderRateLimiter).handleRateLimitReset(account);
verify(rateLimitChallengeListener).handleRateLimitChallengeAnswered(account);
} else {
verifyNoInteractions(preKeyRateLimiter);
verifyNoInteractions(unsealedSenderRateLimiter);
verifyNoInteractions(rateLimitChallengeListener);
}
}
@ParameterizedTest
@MethodSource
void isClientBelowMinimumVersion(final String userAgent, final boolean expectBelowMinimumVersion) {
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(expectBelowMinimumVersion, rateLimitChallengeManager.isClientBelowMinimumVersion(userAgent));
}
private static Stream<Arguments> isClientBelowMinimumVersion() {
return Stream.of(
Arguments.of("Signal-Android/5.1.2 Android/30", true),
Arguments.of("Signal-Android/5.6.0 Android/30", false),
Arguments.of("Signal-Android/5.11.1 Android/30", false),
Arguments.of("Signal-Desktop/5.0.0-beta.3 macOS/11", false),
Arguments.of("Signal-Desktop/5.0.0-beta.1 Windows/3.1", true),
Arguments.of("Signal-Desktop/5.2.0 Debian/11", false),
Arguments.of("Signal-iOS/5.1.2 iOS/12.2", true),
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(UUID.class), anyInt())).thenReturn(captchaAttemptPermitted);
when(recaptchaChallengeSuccessLimiter.hasAvailablePermits(any(UUID.class), anyInt())).thenReturn(captchaSuccessPermitted);
when(pushChallengeAttemptLimiter.hasAvailablePermits(any(UUID.class), anyInt())).thenReturn(pushAttemptPermitted);
when(pushChallengeSuccessLimiter.hasAvailablePermits(any(UUID.class), anyInt())).thenReturn(pushSuccessPermitted);
final int expectedLength = (expectCaptcha ? 1 : 0) + (expectPushChallenge ? 1 : 0);
final Account account = mock(Account.class);
when(account.getUuid()).thenReturn(UUID.randomUUID());
final List<String> options = rateLimitChallengeManager.getChallengeOptions(account);
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,139 @@
/*
* Copyright 2013-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
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.Mockito.mock;
import static org.mockito.Mockito.when;
import com.vdurmont.semver4j.Semver;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
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.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRateLimitChallengeConfiguration;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
class RateLimitChallengeOptionManagerTest {
private DynamicRateLimitChallengeConfiguration rateLimitChallengeConfiguration;
private DynamicRateLimiters rateLimiters;
private RateLimitChallengeOptionManager rateLimitChallengeOptionManager;
@BeforeEach
void setUp() {
rateLimiters = mock(DynamicRateLimiters.class);
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =
mock(DynamicConfigurationManager.class);
final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
rateLimitChallengeConfiguration = mock(DynamicRateLimitChallengeConfiguration.class);
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
when(dynamicConfiguration.getRateLimitChallengeConfiguration()).thenReturn(rateLimitChallengeConfiguration);
rateLimitChallengeOptionManager = new RateLimitChallengeOptionManager(rateLimiters, dynamicConfigurationManager);
}
@ParameterizedTest
@MethodSource
void isClientBelowMinimumVersion(final String userAgent, final boolean expectBelowMinimumVersion) {
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(expectBelowMinimumVersion, rateLimitChallengeOptionManager.isClientBelowMinimumVersion(userAgent));
}
private static Stream<Arguments> isClientBelowMinimumVersion() {
return Stream.of(
Arguments.of("Signal-Android/5.1.2 Android/30", true),
Arguments.of("Signal-Android/5.6.0 Android/30", false),
Arguments.of("Signal-Android/5.11.1 Android/30", false),
Arguments.of("Signal-Desktop/5.0.0-beta.3 macOS/11", false),
Arguments.of("Signal-Desktop/5.0.0-beta.1 Windows/3.1", true),
Arguments.of("Signal-Desktop/5.2.0 Debian/11", false),
Arguments.of("Signal-iOS/5.1.2 iOS/12.2", true),
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(UUID.class), anyInt())).thenReturn(captchaAttemptPermitted);
when(recaptchaChallengeSuccessLimiter.hasAvailablePermits(any(UUID.class), anyInt())).thenReturn(captchaSuccessPermitted);
when(pushChallengeAttemptLimiter.hasAvailablePermits(any(UUID.class), anyInt())).thenReturn(pushAttemptPermitted);
when(pushChallengeSuccessLimiter.hasAvailablePermits(any(UUID.class), anyInt())).thenReturn(pushSuccessPermitted);
final int expectedLength = (expectCaptcha ? 1 : 0) + (expectPushChallenge ? 1 : 0);
final Account account = mock(Account.class);
when(account.getUuid()).thenReturn(UUID.randomUUID());
final List<String> options = rateLimitChallengeOptionManager.getChallengeOptions(account);
assertEquals(expectedLength, options.size());
if (expectCaptcha) {
assertTrue(options.contains(RateLimitChallengeOptionManager.OPTION_RECAPTCHA));
}
if (expectPushChallenge) {
assertTrue(options.contains(RateLimitChallengeOptionManager.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

@@ -1,61 +0,0 @@
package org.whispersystems.textsecuregcm.limits;
import static org.junit.jupiter.api.Assertions.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.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.whispersystems.textsecuregcm.redis.RedisClusterExtension;
import org.whispersystems.textsecuregcm.storage.Account;
class RateLimitResetMetricsManagerTest {
@RegisterExtension
static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();
private RateLimitResetMetricsManager metricsManager;
private SimpleMeterRegistry meterRegistry;
@BeforeEach
void setUp() {
meterRegistry = new SimpleMeterRegistry();
metricsManager = new RateLimitResetMetricsManager(REDIS_CLUSTER_EXTENSION.getRedisCluster(), meterRegistry);
}
@Test
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 = REDIS_CLUSTER_EXTENSION.getRedisCluster()
.withCluster(conn -> conn.sync().pfcount("enforced"));
assertEquals(1L, enforcedCount);
final long unenforcedCount = REDIS_CLUSTER_EXTENSION.getRedisCluster()
.withCluster(conn -> conn.sync().pfcount("unenforced"));
assertEquals(1L, unenforcedCount);
final long total = REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(conn -> conn.sync().pfcount("total"));
assertEquals(2L, total);
}
}

View File

@@ -1,120 +0,0 @@
/*
* Copyright 2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.limits;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
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.RedisClusterExtension;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
class UnsealedSenderRateLimiterTest {
@RegisterExtension
static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();
private Account sender;
private Account firstDestination;
private Account secondDestination;
private UnsealedSenderRateLimiter unsealedSenderRateLimiter;
private DynamicRateLimitChallengeConfiguration rateLimitChallengeConfiguration;
@BeforeEach
void setUp() throws Exception {
final DynamicRateLimiters rateLimiters = mock(DynamicRateLimiters.class);
final CardinalityRateLimiter cardinalityRateLimiter =
new CardinalityRateLimiter(REDIS_CLUSTER_EXTENSION.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, REDIS_CLUSTER_EXTENSION.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
void validate() throws RateLimitExceededException {
unsealedSenderRateLimiter.validate(sender, firstDestination);
assertThrows(RateLimitExceededException.class, () -> unsealedSenderRateLimiter.validate(sender, secondDestination));
unsealedSenderRateLimiter.validate(sender, firstDestination);
}
@Test
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
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);
}
}

View File

@@ -52,11 +52,9 @@ import org.whispersystems.textsecuregcm.entities.PreKeyResponse;
import org.whispersystems.textsecuregcm.entities.PreKeyState;
import org.whispersystems.textsecuregcm.entities.RateLimitChallenge;
import org.whispersystems.textsecuregcm.entities.SignedPreKey;
import org.whispersystems.textsecuregcm.limits.PreKeyRateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.mappers.RateLimitChallengeExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
@@ -97,8 +95,6 @@ class KeysControllerTest {
private final static Keys KEYS = mock(Keys.class );
private final static AccountsManager accounts = mock(AccountsManager.class );
private final static PreKeyRateLimiter preKeyRateLimiter = mock(PreKeyRateLimiter.class );
private final static RateLimitChallengeManager rateLimitChallengeManager = mock(RateLimitChallengeManager.class );
private final static Account existsAccount = mock(Account.class );
private static final RateLimiters rateLimiters = mock(RateLimiters.class);
@@ -109,10 +105,8 @@ class KeysControllerTest {
.addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(ImmutableSet.of(
AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class)))
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new RateLimitChallengeExceptionMapper(rateLimitChallengeManager))
.addResource(new ServerRejectedExceptionMapper())
.addResource(
new KeysController(rateLimiters, KEYS, accounts, preKeyRateLimiter, rateLimitChallengeManager))
.addResource(new KeysController(rateLimiters, KEYS, accounts))
.build();
@BeforeEach
@@ -190,11 +184,9 @@ class KeysControllerTest {
reset(
KEYS,
accounts,
preKeyRateLimiter,
existsAccount,
rateLimiters,
rateLimiter,
rateLimitChallengeManager
rateLimiter
);
clearInvocations(AuthHelper.VALID_DEVICE);
@@ -582,51 +574,4 @@ class KeysControllerTest {
verify(AuthHelper.DISABLED_DEVICE).setSignedPreKey(eq(signedPreKey));
verify(accounts).update(eq(AuthHelper.DISABLED_ACCOUNT), any());
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void testRateLimitChallenge(boolean clientBelowMinimumVersion) throws RateLimitExceededException {
Duration retryAfter = Duration.ofMinutes(1);
doThrow(new RateLimitExceededException(retryAfter))
.when(preKeyRateLimiter).validate(any());
when(
rateLimitChallengeManager.isClientBelowMinimumVersion("Signal-Android/5.1.2 Android/30")).thenReturn(
clientBelowMinimumVersion);
when(rateLimitChallengeManager.getChallengeOptions(AuthHelper.VALID_ACCOUNT))
.thenReturn(
List.of(RateLimitChallengeManager.OPTION_PUSH_CHALLENGE, RateLimitChallengeManager.OPTION_RECAPTCHA));
Response result = resources.getJerseyTest()
.target(String.format("/v2/keys/%s/*", EXISTS_UUID.toString()))
.request()
.header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader("1337".getBytes()))
.header("User-Agent", "Signal-Android/5.1.2 Android/30")
.get();
// unidentified access should not be rate limited
assertThat(result.getStatus()).isEqualTo(200);
result = resources.getJerseyTest()
.target(String.format("/v2/keys/%s/*", EXISTS_UUID.toString()))
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.header("User-Agent", "Signal-Android/5.1.2 Android/30")
.get();
if (clientBelowMinimumVersion) {
assertThat(result.getStatus()).isEqualTo(508);
} else {
assertThat(result.getStatus()).isEqualTo(428);
RateLimitChallenge rateLimitChallenge = result.readEntity(RateLimitChallenge.class);
assertThat(rateLimitChallenge.getToken()).isNotBlank();
assertThat(rateLimitChallenge.getOptions()).isNotEmpty();
assertThat(rateLimitChallenge.getOptions()).contains("recaptcha");
assertThat(rateLimitChallenge.getOptions()).contains("pushChallenge");
assertThat(Long.parseLong(result.getHeaderString("Retry-After"))).isEqualTo(retryAfter.toSeconds());
}
}
}

View File

@@ -6,14 +6,11 @@ import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.Duration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration;
import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration.RateLimitConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRateLimitsConfiguration;
import org.whispersystems.textsecuregcm.limits.CardinalityRateLimiter;
import org.whispersystems.textsecuregcm.limits.DynamicRateLimiters;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
@@ -38,11 +35,11 @@ class DynamicRateLimitsTest {
void testUnchangingConfiguration() {
DynamicRateLimiters rateLimiters = new DynamicRateLimiters(redisCluster, dynamicConfig);
RateLimiter limiter = rateLimiters.getUnsealedIpLimiter();
RateLimiter limiter = rateLimiters.getRateLimitResetLimiter();
assertThat(limiter.getBucketSize()).isEqualTo(dynamicConfig.getConfiguration().getLimits().getUnsealedSenderIp().getBucketSize());
assertThat(limiter.getLeakRatePerMinute()).isEqualTo(dynamicConfig.getConfiguration().getLimits().getUnsealedSenderIp().getLeakRatePerMinute());
assertSame(rateLimiters.getUnsealedIpLimiter(), limiter);
assertThat(limiter.getBucketSize()).isEqualTo(dynamicConfig.getConfiguration().getLimits().getRateLimitReset().getBucketSize());
assertThat(limiter.getLeakRatePerMinute()).isEqualTo(dynamicConfig.getConfiguration().getLimits().getRateLimitReset().getLeakRatePerMinute());
assertSame(rateLimiters.getRateLimitResetLimiter(), limiter);
}
@Test
@@ -51,33 +48,30 @@ class DynamicRateLimitsTest {
DynamicRateLimitsConfiguration limitsConfiguration = mock(DynamicRateLimitsConfiguration.class);
when(configuration.getLimits()).thenReturn(limitsConfiguration);
when(limitsConfiguration.getUnsealedSenderNumber()).thenReturn(new RateLimitsConfiguration.CardinalityRateLimitConfiguration(10, Duration.ofHours(1)));
when(limitsConfiguration.getRecaptchaChallengeAttempt()).thenReturn(new RateLimitConfiguration());
when(limitsConfiguration.getRecaptchaChallengeSuccess()).thenReturn(new RateLimitConfiguration());
when(limitsConfiguration.getPushChallengeAttempt()).thenReturn(new RateLimitConfiguration());
when(limitsConfiguration.getPushChallengeSuccess()).thenReturn(new RateLimitConfiguration());
when(limitsConfiguration.getDailyPreKeys()).thenReturn(new RateLimitConfiguration());
final RateLimitConfiguration initialRateLimitConfiguration = new RateLimitConfiguration(4, 1.0);
when(limitsConfiguration.getUnsealedSenderIp()).thenReturn(initialRateLimitConfiguration);
final RateLimitConfiguration initialRateLimitConfiguration = new RateLimitConfiguration(4, 1);
when(limitsConfiguration.getRateLimitReset()).thenReturn(initialRateLimitConfiguration);
when(dynamicConfig.getConfiguration()).thenReturn(configuration);
DynamicRateLimiters rateLimiters = new DynamicRateLimiters(redisCluster, dynamicConfig);
CardinalityRateLimiter limiter = rateLimiters.getUnsealedSenderCardinalityLimiter();
RateLimiter limiter = rateLimiters.getRateLimitResetLimiter();
assertThat(limiter.getDefaultMaxCardinality()).isEqualTo(10);
assertThat(limiter.getInitialTtl()).isEqualTo(Duration.ofHours(1));
assertSame(rateLimiters.getUnsealedSenderCardinalityLimiter(), limiter);
assertThat(limiter.getBucketSize()).isEqualTo(4);
assertThat(limiter.getLeakRatePerMinute()).isEqualTo(1);
assertSame(rateLimiters.getRateLimitResetLimiter(), limiter);
when(limitsConfiguration.getUnsealedSenderNumber()).thenReturn(new RateLimitsConfiguration.CardinalityRateLimitConfiguration(20, Duration.ofHours(2)));
when(limitsConfiguration.getRateLimitReset()).thenReturn(new RateLimitConfiguration(17, 19));
CardinalityRateLimiter changed = rateLimiters.getUnsealedSenderCardinalityLimiter();
RateLimiter changed = rateLimiters.getRateLimitResetLimiter();
assertThat(changed.getDefaultMaxCardinality()).isEqualTo(20);
assertThat(changed.getInitialTtl()).isEqualTo(Duration.ofHours(2));
assertThat(changed.getBucketSize()).isEqualTo(17);
assertThat(changed.getLeakRatePerMinute()).isEqualTo(19);
assertNotSame(limiter, changed);
}