mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-20 00:19:22 +01:00
Add client challenges for prekey and message rate limiters
This commit is contained in:
@@ -6,7 +6,6 @@
|
||||
package org.whispersystems.textsecuregcm.limits;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Random;
|
||||
import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration.CardinalityRateLimitConfiguration;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||
@@ -24,25 +23,22 @@ public class CardinalityRateLimiter {
|
||||
private final String name;
|
||||
|
||||
private final Duration ttl;
|
||||
private final Duration ttlJitter;
|
||||
private final int maxCardinality;
|
||||
private final int defaultMaxCardinality;
|
||||
|
||||
private final Random random = new Random();
|
||||
|
||||
public CardinalityRateLimiter(final FaultTolerantRedisCluster cacheCluster, final String name, final Duration ttl, final Duration ttlJitter, final int maxCardinality) {
|
||||
public CardinalityRateLimiter(final FaultTolerantRedisCluster cacheCluster, final String name, final Duration ttl, final int defaultMaxCardinality) {
|
||||
this.cacheCluster = cacheCluster;
|
||||
|
||||
this.name = name;
|
||||
|
||||
this.ttl = ttl;
|
||||
this.ttlJitter = ttlJitter;
|
||||
this.maxCardinality = maxCardinality;
|
||||
this.defaultMaxCardinality = defaultMaxCardinality;
|
||||
}
|
||||
|
||||
public void validate(final String key, final String target) throws RateLimitExceededException {
|
||||
final String hllKey = getHllKey(key);
|
||||
public void validate(final String key, final String target, final int maxCardinality) throws RateLimitExceededException {
|
||||
|
||||
final boolean rateLimitExceeded = cacheCluster.withCluster(connection -> {
|
||||
final String hllKey = getHllKey(key);
|
||||
|
||||
final boolean changed = connection.sync().pfadd(hllKey, target) == 1;
|
||||
final long cardinality = connection.sync().pfcount(hllKey);
|
||||
|
||||
@@ -51,16 +47,14 @@ public class CardinalityRateLimiter {
|
||||
// If the set already existed, we can assume it already had an expiration time and can save a round trip by
|
||||
// skipping the ttl check.
|
||||
if (mayNeedExpiration && connection.sync().ttl(hllKey) == -1) {
|
||||
final long expireSeconds = ttl.plusSeconds(random.nextInt((int) ttlJitter.toSeconds())).toSeconds();
|
||||
connection.sync().expire(hllKey, expireSeconds);
|
||||
connection.sync().expire(hllKey, ttl.toSeconds());
|
||||
}
|
||||
|
||||
return changed && cardinality > maxCardinality;
|
||||
});
|
||||
|
||||
if (rateLimitExceeded) {
|
||||
// Using the TTL as the "retry after" time isn't EXACTLY right, but it's a reasonable approximation
|
||||
throw new RateLimitExceededException(ttl);
|
||||
throw new RateLimitExceededException(Duration.ofSeconds(getRemainingTtl(key)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,21 +62,20 @@ public class CardinalityRateLimiter {
|
||||
return "hll_rate_limit::" + name + "::" + key;
|
||||
}
|
||||
|
||||
public Duration getTtl() {
|
||||
public Duration getInitialTtl() {
|
||||
return ttl;
|
||||
}
|
||||
|
||||
public Duration getTtlJitter() {
|
||||
return ttlJitter;
|
||||
public long getRemainingTtl(final String key) {
|
||||
return cacheCluster.withCluster(connection -> connection.sync().ttl(getHllKey(key)));
|
||||
}
|
||||
|
||||
public int getMaxCardinality() {
|
||||
return maxCardinality;
|
||||
public int getDefaultMaxCardinality() {
|
||||
return defaultMaxCardinality;
|
||||
}
|
||||
|
||||
public boolean hasConfiguration(final CardinalityRateLimitConfiguration configuration) {
|
||||
return maxCardinality == configuration.getMaxCardinality() &&
|
||||
ttl.equals(configuration.getTtl()) &&
|
||||
ttlJitter.equals(configuration.getTtlJitter());
|
||||
return defaultMaxCardinality == configuration.getMaxCardinality() && ttl.equals(configuration.getTtl());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.limits;
|
||||
|
||||
import static com.codahale.metrics.MetricRegistry.name;
|
||||
|
||||
import io.dropwizard.util.Duration;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
public class PreKeyRateLimiter {
|
||||
|
||||
private static final String RATE_LIMIT_RESET_COUNTER_NAME = name(PreKeyRateLimiter.class, "reset");
|
||||
private static final String RATE_LIMITED_PREKEYS_COUNTER_NAME = name(PreKeyRateLimiter.class, "rateLimited");
|
||||
private static final String RATE_LIMITED_PREKEYS_TOTAL_ACCOUNTS_COUNTER_NAME = name(PreKeyRateLimiter.class, "rateLimited");
|
||||
private static final String RATE_LIMITED_PREKEYS_ACCOUNTS_ENFORCED_COUNTER_NAME = name(PreKeyRateLimiter.class, "rateLimitedAccountsEnforced");
|
||||
private static final String RATE_LIMITED_PREKEYS_ACCOUNTS_UNENFORCED_COUNTER_NAME = name(PreKeyRateLimiter.class, "rateLimitedAccountsUnenforced");
|
||||
|
||||
private static final String RATE_LIMITED_ACCOUNTS_HLL_KEY = "PreKeyRateLimiter::rateLimitedAccounts";
|
||||
private static final String RATE_LIMITED_ACCOUNTS_ENFORCED_HLL_KEY = "PreKeyRateLimiter::rateLimitedAccounts::enforced";
|
||||
private static final String RATE_LIMITED_ACCOUNTS_UNENFORCED_HLL_KEY = "PreKeyRateLimiter::rateLimitedAccounts::unenforced";
|
||||
private static final long RATE_LIMITED_ACCOUNTS_HLL_TTL_SECONDS = Duration.days(1).toSeconds();
|
||||
|
||||
private final RateLimiters rateLimiters;
|
||||
private final DynamicConfigurationManager dynamicConfigurationManager;
|
||||
private final RateLimitResetMetricsManager metricsManager;
|
||||
|
||||
public PreKeyRateLimiter(final RateLimiters rateLimiters,
|
||||
final DynamicConfigurationManager dynamicConfigurationManager,
|
||||
final RateLimitResetMetricsManager metricsManager) {
|
||||
this.rateLimiters = rateLimiters;
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
this.metricsManager = metricsManager;
|
||||
|
||||
metricsManager.initializeFunctionCounters(RATE_LIMITED_PREKEYS_TOTAL_ACCOUNTS_COUNTER_NAME,
|
||||
RATE_LIMITED_ACCOUNTS_HLL_KEY);
|
||||
metricsManager.initializeFunctionCounters(RATE_LIMITED_PREKEYS_ACCOUNTS_ENFORCED_COUNTER_NAME,
|
||||
RATE_LIMITED_ACCOUNTS_ENFORCED_HLL_KEY);
|
||||
metricsManager.initializeFunctionCounters(RATE_LIMITED_PREKEYS_ACCOUNTS_UNENFORCED_COUNTER_NAME,
|
||||
RATE_LIMITED_ACCOUNTS_UNENFORCED_HLL_KEY);
|
||||
}
|
||||
|
||||
public void validate(final Account account) throws RateLimitExceededException {
|
||||
|
||||
try {
|
||||
rateLimiters.getDailyPreKeysLimiter().validate(account.getNumber());
|
||||
} catch (final RateLimitExceededException e) {
|
||||
|
||||
final boolean enforceLimit = dynamicConfigurationManager.getConfiguration()
|
||||
.getRateLimitChallengeConfiguration().isPreKeyLimitEnforced();
|
||||
|
||||
metricsManager.recordMetrics(account, enforceLimit,
|
||||
RATE_LIMITED_PREKEYS_COUNTER_NAME,
|
||||
enforceLimit ? RATE_LIMITED_ACCOUNTS_ENFORCED_HLL_KEY : RATE_LIMITED_ACCOUNTS_UNENFORCED_HLL_KEY,
|
||||
RATE_LIMITED_ACCOUNTS_HLL_KEY,
|
||||
RATE_LIMITED_ACCOUNTS_HLL_TTL_SECONDS
|
||||
);
|
||||
|
||||
if (enforceLimit) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void handleRateLimitReset(final Account account) {
|
||||
|
||||
rateLimiters.getDailyPreKeysLimiter().clear(account.getNumber());
|
||||
|
||||
Metrics.counter(RATE_LIMIT_RESET_COUNTER_NAME, "countryCode", Util.getCountryCode(account.getNumber()))
|
||||
.increment();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.limits;
|
||||
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import org.apache.commons.codec.DecoderException;
|
||||
import org.apache.commons.codec.binary.Hex;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.whispersystems.textsecuregcm.push.APNSender;
|
||||
import org.whispersystems.textsecuregcm.push.ApnMessage;
|
||||
import org.whispersystems.textsecuregcm.push.ApnMessage.Type;
|
||||
import org.whispersystems.textsecuregcm.push.GCMSender;
|
||||
import org.whispersystems.textsecuregcm.push.GcmMessage;
|
||||
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.storage.PushChallengeDynamoDb;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
|
||||
import static com.codahale.metrics.MetricRegistry.name;
|
||||
|
||||
public class PushChallengeManager {
|
||||
private final APNSender apnSender;
|
||||
private final GCMSender gcmSender;
|
||||
|
||||
private final PushChallengeDynamoDb pushChallengeDynamoDb;
|
||||
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
private static final int CHALLENGE_TOKEN_LENGTH = 16;
|
||||
private static final Duration CHALLENGE_TTL = Duration.ofMinutes(5);
|
||||
|
||||
private static final String CHALLENGE_REQUESTED_COUNTER_NAME = name(PushChallengeManager.class, "requested");
|
||||
private static final String CHALLENGE_ANSWERED_COUNTER_NAME = name(PushChallengeManager.class, "answered");
|
||||
|
||||
private static final String PLATFORM_TAG_NAME = "platform";
|
||||
private static final String SENT_TAG_NAME = "sent";
|
||||
private static final String SUCCESS_TAG_NAME = "success";
|
||||
|
||||
public PushChallengeManager(final APNSender apnSender, final GCMSender gcmSender,
|
||||
final PushChallengeDynamoDb pushChallengeDynamoDb) {
|
||||
|
||||
this.apnSender = apnSender;
|
||||
this.gcmSender = gcmSender;
|
||||
this.pushChallengeDynamoDb = pushChallengeDynamoDb;
|
||||
}
|
||||
|
||||
public void sendChallenge(final Account account) throws NotPushRegisteredException {
|
||||
final Device masterDevice = account.getMasterDevice().orElseThrow(NotPushRegisteredException::new);
|
||||
|
||||
if (StringUtils.isAllBlank(masterDevice.getGcmId(), masterDevice.getApnId())) {
|
||||
throw new NotPushRegisteredException();
|
||||
}
|
||||
|
||||
final byte[] token = new byte[CHALLENGE_TOKEN_LENGTH];
|
||||
random.nextBytes(token);
|
||||
|
||||
final boolean sent;
|
||||
final String platform;
|
||||
|
||||
if (pushChallengeDynamoDb.add(account.getUuid(), token, CHALLENGE_TTL)) {
|
||||
final String tokenHex = Hex.encodeHexString(token);
|
||||
sent = true;
|
||||
|
||||
if (StringUtils.isNotBlank(masterDevice.getGcmId())) {
|
||||
gcmSender.sendMessage(new GcmMessage(masterDevice.getGcmId(), account.getNumber(), 0, GcmMessage.Type.RATE_LIMIT_CHALLENGE, Optional.of(tokenHex)));
|
||||
platform = ClientPlatform.ANDROID.name().toLowerCase();
|
||||
} else if (StringUtils.isNotBlank(masterDevice.getApnId())) {
|
||||
apnSender.sendMessage(new ApnMessage(masterDevice.getApnId(), account.getNumber(), 0, false, Type.RATE_LIMIT_CHALLENGE, Optional.of(tokenHex)));
|
||||
platform = ClientPlatform.IOS.name().toLowerCase();
|
||||
} else {
|
||||
throw new AssertionError();
|
||||
}
|
||||
} else {
|
||||
sent = false;
|
||||
platform = null;
|
||||
}
|
||||
|
||||
Metrics.counter(CHALLENGE_REQUESTED_COUNTER_NAME,
|
||||
PLATFORM_TAG_NAME, platform,
|
||||
SENT_TAG_NAME, String.valueOf(sent)).increment();
|
||||
}
|
||||
|
||||
public boolean answerChallenge(final Account account, final String challengeTokenHex) {
|
||||
boolean success = false;
|
||||
|
||||
try {
|
||||
success = pushChallengeDynamoDb.remove(account.getUuid(), Hex.decodeHex(challengeTokenHex));
|
||||
} catch (final DecoderException ignored) {
|
||||
}
|
||||
|
||||
final String platform = account.getMasterDevice().map(masterDevice -> {
|
||||
if (StringUtils.isNotBlank(masterDevice.getGcmId())) {
|
||||
return ClientPlatform.IOS.name().toLowerCase();
|
||||
} else if (StringUtils.isNotBlank(masterDevice.getApnId())) {
|
||||
return ClientPlatform.ANDROID.name().toLowerCase();
|
||||
} else {
|
||||
return "unknown";
|
||||
}
|
||||
}).orElse("unknown");
|
||||
|
||||
|
||||
Metrics.counter(CHALLENGE_ANSWERED_COUNTER_NAME,
|
||||
PLATFORM_TAG_NAME, platform,
|
||||
SUCCESS_TAG_NAME, String.valueOf(success)).increment();
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.whispersystems.textsecuregcm.limits;
|
||||
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import java.time.Duration;
|
||||
|
||||
public class RateLimitChallengeException extends Exception {
|
||||
|
||||
private final Account account;
|
||||
private final Duration retryAfter;
|
||||
|
||||
public RateLimitChallengeException(final Account account, final Duration retryAfter) {
|
||||
this.account = account;
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
|
||||
public Account getAccount() {
|
||||
return account;
|
||||
}
|
||||
|
||||
public Duration getRetryAfter() {
|
||||
return retryAfter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package org.whispersystems.textsecuregcm.limits;
|
||||
|
||||
import com.vdurmont.semver4j.Semver;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.push.NotPushRegisteredException;
|
||||
import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgent;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
||||
|
||||
public class RateLimitChallengeManager {
|
||||
|
||||
private final PushChallengeManager pushChallengeManager;
|
||||
private final RecaptchaClient recaptchaClient;
|
||||
|
||||
private final PreKeyRateLimiter preKeyRateLimiter;
|
||||
private final UnsealedSenderRateLimiter unsealedSenderRateLimiter;
|
||||
|
||||
private final RateLimiters rateLimiters;
|
||||
private final DynamicConfigurationManager dynamicConfigurationManager;
|
||||
|
||||
public static final String OPTION_RECAPTCHA = "recaptcha";
|
||||
public static final String OPTION_PUSH_CHALLENGE = "pushChallenge";
|
||||
|
||||
public RateLimitChallengeManager(
|
||||
final PushChallengeManager pushChallengeManager,
|
||||
final RecaptchaClient recaptchaClient,
|
||||
final PreKeyRateLimiter preKeyRateLimiter,
|
||||
final UnsealedSenderRateLimiter unsealedSenderRateLimiter,
|
||||
final RateLimiters rateLimiters,
|
||||
final DynamicConfigurationManager dynamicConfigurationManager) {
|
||||
|
||||
this.pushChallengeManager = pushChallengeManager;
|
||||
this.recaptchaClient = recaptchaClient;
|
||||
this.preKeyRateLimiter = preKeyRateLimiter;
|
||||
this.unsealedSenderRateLimiter = unsealedSenderRateLimiter;
|
||||
this.rateLimiters = rateLimiters;
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
}
|
||||
|
||||
public void answerPushChallenge(final Account account, final String challenge) throws RateLimitExceededException {
|
||||
rateLimiters.getPushChallengeAttemptLimiter().validate(account.getNumber());
|
||||
|
||||
final boolean challengeSuccess = pushChallengeManager.answerChallenge(account, challenge);
|
||||
|
||||
if (challengeSuccess) {
|
||||
rateLimiters.getPushChallengeSuccessLimiter().validate(account.getNumber());
|
||||
resetRateLimits(account);
|
||||
}
|
||||
}
|
||||
|
||||
public void answerRecaptchaChallenge(final Account account, final String captcha, final String mostRecentProxyIp)
|
||||
throws RateLimitExceededException {
|
||||
|
||||
rateLimiters.getRecaptchaChallengeAttemptLimiter().validate(account.getNumber());
|
||||
|
||||
final boolean challengeSuccess = recaptchaClient.verify(captcha, mostRecentProxyIp);
|
||||
|
||||
if (challengeSuccess) {
|
||||
rateLimiters.getRecaptchaChallengeSuccessLimiter().validate(account.getNumber());
|
||||
resetRateLimits(account);
|
||||
}
|
||||
}
|
||||
|
||||
private void resetRateLimits(final Account account) throws RateLimitExceededException {
|
||||
rateLimiters.getRateLimitResetLimiter().validate(account.getNumber());
|
||||
|
||||
preKeyRateLimiter.handleRateLimitReset(account);
|
||||
unsealedSenderRateLimiter.handleRateLimitReset(account);
|
||||
}
|
||||
|
||||
public boolean shouldIssueRateLimitChallenge(final String userAgent) {
|
||||
try {
|
||||
final UserAgent client = UserAgentUtil.parseUserAgentString(userAgent);
|
||||
final Optional<Semver> minimumClientVersion = dynamicConfigurationManager.getConfiguration()
|
||||
.getRateLimitChallengeConfiguration()
|
||||
.getMinimumSupportedVersion(client.getPlatform());
|
||||
|
||||
return minimumClientVersion.map(version -> version.isLowerThanOrEqualTo(client.getVersion()))
|
||||
.orElse(false);
|
||||
} catch (final UnrecognizedUserAgentException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> getChallengeOptions(final Account account) {
|
||||
final List<String> options = new ArrayList<>(2);
|
||||
|
||||
final String key = account.getNumber();
|
||||
|
||||
if (rateLimiters.getRecaptchaChallengeAttemptLimiter().hasAvailablePermits(key, 1) &&
|
||||
rateLimiters.getRecaptchaChallengeSuccessLimiter().hasAvailablePermits(key, 1)) {
|
||||
|
||||
options.add(OPTION_RECAPTCHA);
|
||||
}
|
||||
|
||||
if (rateLimiters.getPushChallengeAttemptLimiter().hasAvailablePermits(key, 1) &&
|
||||
rateLimiters.getPushChallengeSuccessLimiter().hasAvailablePermits(key, 1)) {
|
||||
|
||||
options.add(OPTION_PUSH_CHALLENGE);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public void sendPushChallenge(final Account account) throws NotPushRegisteredException {
|
||||
pushChallengeManager.sendChallenge(account);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.whispersystems.textsecuregcm.limits;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.FunctionCounter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
|
||||
public class RateLimitResetMetricsManager {
|
||||
|
||||
private final FaultTolerantRedisCluster metricsCluster;
|
||||
private final MeterRegistry meterRegistry;
|
||||
|
||||
public RateLimitResetMetricsManager(
|
||||
final FaultTolerantRedisCluster metricsCluster, final MeterRegistry meterRegistry) {
|
||||
this.metricsCluster = metricsCluster;
|
||||
this.meterRegistry = meterRegistry;
|
||||
}
|
||||
|
||||
void initializeFunctionCounters(String counterKey, String hllKey) {
|
||||
FunctionCounter.builder(counterKey, null, (ignored) ->
|
||||
metricsCluster.<Long>withCluster(conn -> conn.sync().pfcount(hllKey))).register(meterRegistry);
|
||||
}
|
||||
|
||||
void recordMetrics(Account account, boolean enforced, String counterKey, String hllEnforcedKey, String hllTotalKey,
|
||||
long hllTtl) {
|
||||
|
||||
Counter.builder(counterKey)
|
||||
.tag("enforced", String.valueOf(enforced))
|
||||
.register(meterRegistry)
|
||||
.increment();
|
||||
|
||||
metricsCluster.useCluster(connection -> {
|
||||
connection.sync().pfadd(hllEnforcedKey, account.getUuid().toString());
|
||||
connection.sync().expire(hllEnforcedKey, hllTtl);
|
||||
connection.sync().pfadd(hllTotalKey, account.getUuid().toString());
|
||||
connection.sync().expire(hllTotalKey, hllTtl);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import com.codahale.metrics.Timer;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration.RateLimitConfiguration;
|
||||
@@ -64,6 +65,10 @@ public class RateLimiter {
|
||||
validate(key, 1);
|
||||
}
|
||||
|
||||
public boolean hasAvailablePermits(final String key, final int permits) {
|
||||
return getBucket(key).getTimeUntilSpaceAvailable(permits).equals(Duration.ZERO);
|
||||
}
|
||||
|
||||
public void clear(String key) {
|
||||
cacheCluster.useCluster(connection -> connection.sync().del(getBucketName(key)));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package org.whispersystems.textsecuregcm.limits;
|
||||
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.BiFunction;
|
||||
import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration.CardinalityRateLimitConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration.RateLimitConfiguration;
|
||||
@@ -37,8 +38,14 @@ public class RateLimiters {
|
||||
private final RateLimiter usernameLookupLimiter;
|
||||
private final RateLimiter usernameSetLimiter;
|
||||
|
||||
private final AtomicReference<CardinalityRateLimiter> unsealedSenderLimiter;
|
||||
private final AtomicReference<CardinalityRateLimiter> unsealedSenderCardinalityLimiter;
|
||||
private final AtomicReference<RateLimiter> unsealedIpLimiter;
|
||||
private final AtomicReference<RateLimiter> rateLimitResetLimiter;
|
||||
private final AtomicReference<RateLimiter> recaptchaChallengeAttemptLimiter;
|
||||
private final AtomicReference<RateLimiter> recaptchaChallengeSuccessLimiter;
|
||||
private final AtomicReference<RateLimiter> pushChallengeAttemptLimiter;
|
||||
private final AtomicReference<RateLimiter> pushChallengeSuccessLimiter;
|
||||
private final AtomicReference<RateLimiter> dailyPreKeysLimiter;
|
||||
|
||||
private final FaultTolerantRedisCluster cacheCluster;
|
||||
private final DynamicConfigurationManager dynamicConfig;
|
||||
@@ -119,30 +126,90 @@ public class RateLimiters {
|
||||
config.getUsernameSet().getBucketSize(),
|
||||
config.getUsernameSet().getLeakRatePerMinute());
|
||||
|
||||
this.unsealedSenderLimiter = new AtomicReference<>(createUnsealedSenderLimiter(cacheCluster, dynamicConfig.getConfiguration().getLimits().getUnsealedSenderNumber()));
|
||||
this.dailyPreKeysLimiter = new AtomicReference<>(createDailyPreKeysLimiter(cacheCluster, dynamicConfig.getConfiguration().getLimits().getDailyPreKeys()));
|
||||
|
||||
this.unsealedSenderCardinalityLimiter = new AtomicReference<>(createUnsealedSenderCardinalityLimiter(cacheCluster, dynamicConfig.getConfiguration().getLimits().getUnsealedSenderNumber()));
|
||||
this.unsealedIpLimiter = new AtomicReference<>(createUnsealedIpLimiter(cacheCluster, dynamicConfig.getConfiguration().getLimits().getUnsealedSenderIp()));
|
||||
|
||||
this.rateLimitResetLimiter = new AtomicReference<>(
|
||||
createRateLimitResetLimiter(cacheCluster, dynamicConfig.getConfiguration().getLimits().getRateLimitReset()));
|
||||
|
||||
this.recaptchaChallengeAttemptLimiter = new AtomicReference<>(createRecaptchaChallengeAttemptLimiter(cacheCluster, dynamicConfig.getConfiguration().getLimits().getRecaptchaChallengeAttempt()));
|
||||
this.recaptchaChallengeSuccessLimiter = new AtomicReference<>(createRecaptchaChallengeSuccessLimiter(cacheCluster, dynamicConfig.getConfiguration().getLimits().getRecaptchaChallengeSuccess()));
|
||||
this.pushChallengeAttemptLimiter = new AtomicReference<>(createPushChallengeAttemptLimiter(cacheCluster, dynamicConfig.getConfiguration().getLimits().getPushChallengeAttempt()));
|
||||
this.pushChallengeSuccessLimiter = new AtomicReference<>(createPushChallengeSuccessLimiter(cacheCluster, dynamicConfig.getConfiguration().getLimits().getPushChallengeSuccess()));
|
||||
}
|
||||
|
||||
public CardinalityRateLimiter getUnsealedSenderLimiter() {
|
||||
public CardinalityRateLimiter getUnsealedSenderCardinalityLimiter() {
|
||||
CardinalityRateLimitConfiguration currentConfiguration = dynamicConfig.getConfiguration().getLimits().getUnsealedSenderNumber();
|
||||
|
||||
return this.unsealedSenderLimiter.updateAndGet(rateLimiter -> {
|
||||
return this.unsealedSenderCardinalityLimiter.updateAndGet(rateLimiter -> {
|
||||
if (rateLimiter.hasConfiguration(currentConfiguration)) {
|
||||
return rateLimiter;
|
||||
} else {
|
||||
return createUnsealedSenderLimiter(cacheCluster, currentConfiguration);
|
||||
return createUnsealedSenderCardinalityLimiter(cacheCluster, currentConfiguration);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public RateLimiter getUnsealedIpLimiter() {
|
||||
RateLimitConfiguration currentConfiguration = dynamicConfig.getConfiguration().getLimits().getUnsealedSenderIp();
|
||||
return updateAndGetRateLimiter(
|
||||
unsealedIpLimiter,
|
||||
dynamicConfig.getConfiguration().getLimits().getUnsealedSenderIp(),
|
||||
this::createUnsealedIpLimiter);
|
||||
}
|
||||
|
||||
return this.unsealedIpLimiter.updateAndGet(rateLimiter -> {
|
||||
if (rateLimiter.hasConfiguration(currentConfiguration)) {
|
||||
return rateLimiter;
|
||||
public RateLimiter getRateLimitResetLimiter() {
|
||||
return updateAndGetRateLimiter(
|
||||
rateLimitResetLimiter,
|
||||
dynamicConfig.getConfiguration().getLimits().getRateLimitReset(),
|
||||
this::createRateLimitResetLimiter);
|
||||
}
|
||||
|
||||
public RateLimiter getRecaptchaChallengeAttemptLimiter() {
|
||||
return updateAndGetRateLimiter(
|
||||
recaptchaChallengeAttemptLimiter,
|
||||
dynamicConfig.getConfiguration().getLimits().getRecaptchaChallengeAttempt(),
|
||||
this::createRecaptchaChallengeAttemptLimiter);
|
||||
}
|
||||
|
||||
public RateLimiter getRecaptchaChallengeSuccessLimiter() {
|
||||
return updateAndGetRateLimiter(
|
||||
recaptchaChallengeSuccessLimiter,
|
||||
dynamicConfig.getConfiguration().getLimits().getRecaptchaChallengeSuccess(),
|
||||
this::createRecaptchaChallengeSuccessLimiter);
|
||||
}
|
||||
|
||||
public RateLimiter getPushChallengeAttemptLimiter() {
|
||||
return updateAndGetRateLimiter(
|
||||
pushChallengeAttemptLimiter,
|
||||
dynamicConfig.getConfiguration().getLimits().getPushChallengeAttempt(),
|
||||
this::createPushChallengeAttemptLimiter);
|
||||
}
|
||||
|
||||
public RateLimiter getPushChallengeSuccessLimiter() {
|
||||
return updateAndGetRateLimiter(
|
||||
pushChallengeSuccessLimiter,
|
||||
dynamicConfig.getConfiguration().getLimits().getPushChallengeSuccess(),
|
||||
this::createPushChallengeSuccessLimiter);
|
||||
}
|
||||
|
||||
public RateLimiter getDailyPreKeysLimiter() {
|
||||
return updateAndGetRateLimiter(
|
||||
dailyPreKeysLimiter,
|
||||
dynamicConfig.getConfiguration().getLimits().getDailyPreKeys(),
|
||||
this::createDailyPreKeysLimiter);
|
||||
}
|
||||
|
||||
private RateLimiter updateAndGetRateLimiter(final AtomicReference<RateLimiter> rateLimiter,
|
||||
RateLimitConfiguration currentConfiguration,
|
||||
BiFunction<FaultTolerantRedisCluster, RateLimitConfiguration, RateLimiter> rateLimitFactory) {
|
||||
|
||||
return rateLimiter.updateAndGet(limiter -> {
|
||||
if (limiter.hasConfiguration(currentConfiguration)) {
|
||||
return limiter;
|
||||
} else {
|
||||
return createUnsealedIpLimiter(cacheCluster, currentConfiguration);
|
||||
return rateLimitFactory.apply(cacheCluster, currentConfiguration);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -219,8 +286,8 @@ public class RateLimiters {
|
||||
return usernameSetLimiter;
|
||||
}
|
||||
|
||||
private CardinalityRateLimiter createUnsealedSenderLimiter(FaultTolerantRedisCluster cacheCluster, CardinalityRateLimitConfiguration configuration) {
|
||||
return new CardinalityRateLimiter(cacheCluster, "unsealedSender", configuration.getTtl(), configuration.getTtlJitter(), configuration.getMaxCardinality());
|
||||
private CardinalityRateLimiter createUnsealedSenderCardinalityLimiter(FaultTolerantRedisCluster cacheCluster, CardinalityRateLimitConfiguration configuration) {
|
||||
return new CardinalityRateLimiter(cacheCluster, "unsealedSender", configuration.getTtl(), configuration.getMaxCardinality());
|
||||
}
|
||||
|
||||
private RateLimiter createUnsealedIpLimiter(FaultTolerantRedisCluster cacheCluster, RateLimitConfiguration configuration)
|
||||
@@ -228,6 +295,30 @@ public class RateLimiters {
|
||||
return createLimiter(cacheCluster, configuration, "unsealedIp");
|
||||
}
|
||||
|
||||
public RateLimiter createRateLimitResetLimiter(FaultTolerantRedisCluster cacheCluster, RateLimitConfiguration configuration) {
|
||||
return createLimiter(cacheCluster, configuration, "rateLimitReset");
|
||||
}
|
||||
|
||||
public RateLimiter createRecaptchaChallengeAttemptLimiter(FaultTolerantRedisCluster cacheCluster, RateLimitConfiguration configuration) {
|
||||
return createLimiter(cacheCluster, configuration, "recaptchaChallengeAttempt");
|
||||
}
|
||||
|
||||
public RateLimiter createRecaptchaChallengeSuccessLimiter(FaultTolerantRedisCluster cacheCluster, RateLimitConfiguration configuration) {
|
||||
return createLimiter(cacheCluster, configuration, "recaptchaChallengeSuccess");
|
||||
}
|
||||
|
||||
public RateLimiter createPushChallengeAttemptLimiter(FaultTolerantRedisCluster cacheCluster, RateLimitConfiguration configuration) {
|
||||
return createLimiter(cacheCluster, configuration, "pushChallengeAttempt");
|
||||
}
|
||||
|
||||
public RateLimiter createPushChallengeSuccessLimiter(FaultTolerantRedisCluster cacheCluster, RateLimitConfiguration configuration) {
|
||||
return createLimiter(cacheCluster, configuration, "pushChallengeSuccess");
|
||||
}
|
||||
|
||||
public RateLimiter createDailyPreKeysLimiter(FaultTolerantRedisCluster cacheCluster, RateLimitConfiguration configuration) {
|
||||
return createLimiter(cacheCluster, configuration, "dailyPreKeys");
|
||||
}
|
||||
|
||||
private RateLimiter createLimiter(FaultTolerantRedisCluster cacheCluster, RateLimitConfiguration configuration, String name) {
|
||||
return new RateLimiter(cacheCluster, name,
|
||||
configuration.getBucketSize(),
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.limits;
|
||||
|
||||
import static com.codahale.metrics.MetricRegistry.name;
|
||||
|
||||
import io.dropwizard.util.Duration;
|
||||
import io.lettuce.core.SetArgs;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRateLimitsConfiguration;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
||||
public class UnsealedSenderRateLimiter {
|
||||
|
||||
private final RateLimiters rateLimiters;
|
||||
private final FaultTolerantRedisCluster rateLimitCluster;
|
||||
private final DynamicConfigurationManager dynamicConfigurationManager;
|
||||
private final RateLimitResetMetricsManager metricsManager;
|
||||
|
||||
private static final String RATE_LIMIT_RESET_COUNTER_NAME = name(UnsealedSenderRateLimiter.class, "reset");
|
||||
private static final String RATE_LIMITED_UNSEALED_SENDER_COUNTER_NAME = name(UnsealedSenderRateLimiter.class, "rateLimited");
|
||||
private static final String RATE_LIMITED_UNSEALED_SENDER_ACCOUNTS_TOTAL_COUNTER_NAME = name(UnsealedSenderRateLimiter.class, "rateLimitedAccountsTotal");
|
||||
private static final String RATE_LIMITED_UNSEALED_SENDER_ACCOUNTS_ENFORCED_COUNTER_NAME = name(UnsealedSenderRateLimiter.class, "rateLimitedAccountsEnforced");
|
||||
private static final String RATE_LIMITED_UNSEALED_SENDER_ACCOUNTS_UNENFORCED_COUNTER_NAME = name(UnsealedSenderRateLimiter.class, "rateLimitedAccountsUnenforced");
|
||||
|
||||
private static final String RATE_LIMITED_ACCOUNTS_HLL_KEY = "UnsealedSenderRateLimiter::rateLimitedAccounts::total";
|
||||
private static final String RATE_LIMITED_ACCOUNTS_ENFORCED_HLL_KEY = "UnsealedSenderRateLimiter::rateLimitedAccounts::enforced";
|
||||
private static final String RATE_LIMITED_ACCOUNTS_UNENFORCED_HLL_KEY = "UnsealedSenderRateLimiter::rateLimitedAccounts::unenforced";
|
||||
private static final long RATE_LIMITED_ACCOUNTS_HLL_TTL_SECONDS = Duration.days(1).toSeconds();
|
||||
|
||||
|
||||
public UnsealedSenderRateLimiter(final RateLimiters rateLimiters,
|
||||
final FaultTolerantRedisCluster rateLimitCluster,
|
||||
final DynamicConfigurationManager dynamicConfigurationManager,
|
||||
final RateLimitResetMetricsManager metricsManager) {
|
||||
|
||||
this.rateLimiters = rateLimiters;
|
||||
this.rateLimitCluster = rateLimitCluster;
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
this.metricsManager = metricsManager;
|
||||
|
||||
metricsManager.initializeFunctionCounters(RATE_LIMITED_UNSEALED_SENDER_ACCOUNTS_TOTAL_COUNTER_NAME,
|
||||
RATE_LIMITED_ACCOUNTS_HLL_KEY);
|
||||
metricsManager.initializeFunctionCounters(RATE_LIMITED_UNSEALED_SENDER_ACCOUNTS_ENFORCED_COUNTER_NAME,
|
||||
RATE_LIMITED_ACCOUNTS_ENFORCED_HLL_KEY);
|
||||
metricsManager.initializeFunctionCounters(RATE_LIMITED_UNSEALED_SENDER_ACCOUNTS_UNENFORCED_COUNTER_NAME,
|
||||
RATE_LIMITED_ACCOUNTS_UNENFORCED_HLL_KEY);
|
||||
}
|
||||
|
||||
public void validate(final Account sender, final Account destination) throws RateLimitExceededException {
|
||||
final int maxCardinality = rateLimitCluster.withCluster(connection -> {
|
||||
final String cardinalityString = connection.sync().get(getMaxCardinalityKey(sender));
|
||||
|
||||
return cardinalityString != null
|
||||
? Integer.parseInt(cardinalityString)
|
||||
: dynamicConfigurationManager.getConfiguration().getLimits().getUnsealedSenderDefaultCardinalityLimit();
|
||||
});
|
||||
|
||||
try {
|
||||
rateLimiters.getUnsealedSenderCardinalityLimiter()
|
||||
.validate(sender.getNumber(), destination.getUuid().toString(), maxCardinality);
|
||||
} catch (final RateLimitExceededException e) {
|
||||
|
||||
final boolean enforceLimit = dynamicConfigurationManager.getConfiguration()
|
||||
.getRateLimitChallengeConfiguration().isUnsealedSenderLimitEnforced();
|
||||
|
||||
metricsManager.recordMetrics(sender, enforceLimit, RATE_LIMITED_UNSEALED_SENDER_COUNTER_NAME,
|
||||
enforceLimit ? RATE_LIMITED_ACCOUNTS_ENFORCED_HLL_KEY : RATE_LIMITED_ACCOUNTS_UNENFORCED_HLL_KEY,
|
||||
RATE_LIMITED_ACCOUNTS_HLL_KEY,
|
||||
RATE_LIMITED_ACCOUNTS_HLL_TTL_SECONDS
|
||||
);
|
||||
|
||||
if (enforceLimit) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void handleRateLimitReset(final Account account) {
|
||||
rateLimitCluster.useCluster(connection -> {
|
||||
final CardinalityRateLimiter unsealedSenderCardinalityLimiter = rateLimiters.getUnsealedSenderCardinalityLimiter();
|
||||
final DynamicRateLimitsConfiguration rateLimitsConfiguration =
|
||||
dynamicConfigurationManager.getConfiguration().getLimits();
|
||||
|
||||
final long ttl;
|
||||
{
|
||||
final long remainingTtl = unsealedSenderCardinalityLimiter.getRemainingTtl(account.getNumber());
|
||||
ttl = remainingTtl > 0 ? remainingTtl : unsealedSenderCardinalityLimiter.getInitialTtl().toSeconds();
|
||||
}
|
||||
|
||||
final String key = getMaxCardinalityKey(account);
|
||||
|
||||
connection.sync().set(key,
|
||||
String.valueOf(rateLimitsConfiguration.getUnsealedSenderDefaultCardinalityLimit()),
|
||||
SetArgs.Builder.nx().ex(ttl));
|
||||
|
||||
connection.sync().incrby(key, rateLimitsConfiguration.getUnsealedSenderPermitIncrement());
|
||||
});
|
||||
|
||||
Metrics.counter(RATE_LIMIT_RESET_COUNTER_NAME,
|
||||
"countryCode", Util.getCountryCode(account.getNumber())).increment();
|
||||
}
|
||||
|
||||
private static String getMaxCardinalityKey(final Account account) {
|
||||
return "max_unsealed_sender_cardinality::" + account.getUuid();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user