Add filter-provided captcha score thresholds

This commit is contained in:
Ravi Khadiwala 2023-03-21 17:26:42 -05:00 committed by ravi-signal
parent a8eb27940d
commit ee53260d72
16 changed files with 280 additions and 50 deletions

View File

@ -166,6 +166,7 @@ import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;
import org.whispersystems.textsecuregcm.spam.FilterSpam; import org.whispersystems.textsecuregcm.spam.FilterSpam;
import org.whispersystems.textsecuregcm.spam.RateLimitChallengeListener; import org.whispersystems.textsecuregcm.spam.RateLimitChallengeListener;
import org.whispersystems.textsecuregcm.spam.ReportSpamTokenProvider; import org.whispersystems.textsecuregcm.spam.ReportSpamTokenProvider;
import org.whispersystems.textsecuregcm.spam.ScoreThresholdProvider;
import org.whispersystems.textsecuregcm.spam.SpamFilter; import org.whispersystems.textsecuregcm.spam.SpamFilter;
import org.whispersystems.textsecuregcm.sqs.DirectoryQueue; import org.whispersystems.textsecuregcm.sqs.DirectoryQueue;
import org.whispersystems.textsecuregcm.storage.AccountCleaner; import org.whispersystems.textsecuregcm.storage.AccountCleaner;
@ -780,6 +781,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
webSocketEnvironment.jersey().register(controller); webSocketEnvironment.jersey().register(controller);
} }
WebSocketEnvironment<AuthenticatedAccount> provisioningEnvironment = new WebSocketEnvironment<>(environment, WebSocketEnvironment<AuthenticatedAccount> provisioningEnvironment = new WebSocketEnvironment<>(environment,
webSocketEnvironment.getRequestLog(), 60000); webSocketEnvironment.getRequestLog(), 60000);
provisioningEnvironment.jersey().register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager)); provisioningEnvironment.jersey().register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager));
@ -789,6 +791,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
registerCorsFilter(environment); registerCorsFilter(environment);
registerExceptionMappers(environment, webSocketEnvironment, provisioningEnvironment); registerExceptionMappers(environment, webSocketEnvironment, provisioningEnvironment);
registerProviders(environment, webSocketEnvironment, provisioningEnvironment);
environment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE); environment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);
webSocketEnvironment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE); webSocketEnvironment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);
@ -830,6 +833,15 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
GarbageCollectionGauges.registerMetrics(); GarbageCollectionGauges.registerMetrics();
} }
private void registerProviders(Environment environment,
WebSocketEnvironment<AuthenticatedAccount> webSocketEnvironment,
WebSocketEnvironment<AuthenticatedAccount> provisioningEnvironment) {
environment.jersey().register(ScoreThresholdProvider.ScoreThresholdFeature.class);
webSocketEnvironment.jersey().register(ScoreThresholdProvider.ScoreThresholdFeature.class);
provisioningEnvironment.jersey().register(ScoreThresholdProvider.ScoreThresholdFeature.class);
}
private void registerExceptionMappers(Environment environment, private void registerExceptionMappers(Environment environment,
WebSocketEnvironment<AuthenticatedAccount> webSocketEnvironment, WebSocketEnvironment<AuthenticatedAccount> webSocketEnvironment,
WebSocketEnvironment<AuthenticatedAccount> provisioningEnvironment) { WebSocketEnvironment<AuthenticatedAccount> provisioningEnvironment) {

View File

@ -5,23 +5,111 @@
package org.whispersystems.textsecuregcm.captcha; package org.whispersystems.textsecuregcm.captcha;
/** import java.util.Objects;
* A captcha assessment import java.util.Optional;
*
* @param valid whether the captcha was passed
* @param score string representation of the risk level
*/
public record AssessmentResult(boolean valid, String score) {
public static AssessmentResult invalid() { public class AssessmentResult {
return new AssessmentResult(false, "");
private final boolean solved;
private final float actualScore;
private final float defaultScoreThreshold;
private final String scoreString;
/**
* A captcha assessment
*
* @param solved if false, the captcha was not successfully completed
* @param actualScore float representation of the risk level from [0, 1.0], with 1.0 being the least risky
* @param defaultScoreThreshold the score threshold which the score will be evaluated against by default
* @param scoreString a quantized string representation of the risk level, suitable for use in metrics
*/
private AssessmentResult(boolean solved, float actualScore, float defaultScoreThreshold, final String scoreString) {
this.solved = solved;
this.actualScore = actualScore;
this.defaultScoreThreshold = defaultScoreThreshold;
this.scoreString = scoreString;
} }
/**
* Construct an {@link AssessmentResult} from a captcha evaluation score
*
* @param actualScore the score
* @param defaultScoreThreshold the threshold to compare the score against by default
*/
public static AssessmentResult fromScore(float actualScore, float defaultScoreThreshold) {
if (actualScore < 0 || actualScore > 1.0 || defaultScoreThreshold < 0 || defaultScoreThreshold > 1.0) {
throw new IllegalArgumentException("invalid captcha score");
}
return new AssessmentResult(true, actualScore, defaultScoreThreshold, AssessmentResult.scoreString(actualScore));
}
/**
* Construct a captcha assessment that will always be invalid
*/
public static AssessmentResult invalid() {
return new AssessmentResult(false, 0.0f, 0.0f, "");
}
/**
* Construct a captcha assessment that will always be valid
*/
public static AssessmentResult alwaysValid() {
return new AssessmentResult(true, 1.0f, 0.0f, "1.0");
}
/**
* Check if the captcha assessment should be accepted using the default score threshold
*
* @return true if this assessment should be accepted under the default score threshold
*/
public boolean isValid() {
return isValid(Optional.empty());
}
/**
* Check if the captcha assessment should be accepted
*
* @param scoreThreshold the minimum score the assessment requires to pass, uses default if empty
* @return true if the assessment scored higher than the provided scoreThreshold
*/
public boolean isValid(Optional<Float> scoreThreshold) {
if (!solved) {
return false;
}
return this.actualScore >= scoreThreshold.orElse(this.defaultScoreThreshold);
}
public String getScoreString() {
return scoreString;
}
public float getScore() {
return this.actualScore;
}
/** /**
* Map a captcha score in [0.0, 1.0] to a low cardinality discrete space in [0, 100] suitable for use in metrics * Map a captcha score in [0.0, 1.0] to a low cardinality discrete space in [0, 100] suitable for use in metrics
*/ */
static String scoreString(final float score) { private static String scoreString(final float score) {
final int x = Math.round(score * 10); // [0, 10] final int x = Math.round(score * 10); // [0, 10]
return Integer.toString(x * 10); // [0, 100] in increments of 10 return Integer.toString(x * 10); // [0, 100] in increments of 10
} }
@Override
public boolean equals(final Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
AssessmentResult that = (AssessmentResult) o;
return solved == that.solved && Float.compare(that.actualScore, actualScore) == 0
&& Float.compare(that.defaultScoreThreshold, defaultScoreThreshold) == 0 && Objects.equals(scoreString,
that.scoreString);
}
@Override
public int hashCode() {
return Objects.hash(solved, actualScore, defaultScoreThreshold, scoreString);
}
} }

View File

@ -91,8 +91,7 @@ public class CaptchaChecker {
final AssessmentResult result = client.verify(siteKey, parsedAction, token, ip); final AssessmentResult result = client.verify(siteKey, parsedAction, token, ip);
Metrics.counter(ASSESSMENTS_COUNTER_NAME, Metrics.counter(ASSESSMENTS_COUNTER_NAME,
"action", action, "action", action,
"valid", String.valueOf(result.valid()), "score", result.getScoreString(),
"score", result.score(),
"provider", prefix) "provider", prefix)
.increment(); .increment();
return result; return result;

View File

@ -33,7 +33,6 @@ public class HCaptchaClient implements CaptchaClient {
private static final String PREFIX = "signal-hcaptcha"; private static final String PREFIX = "signal-hcaptcha";
private static final String ASSESSMENT_REASON_COUNTER_NAME = name(HCaptchaClient.class, "assessmentReason"); private static final String ASSESSMENT_REASON_COUNTER_NAME = name(HCaptchaClient.class, "assessmentReason");
private static final String INVALID_REASON_COUNTER_NAME = name(HCaptchaClient.class, "invalidReason"); private static final String INVALID_REASON_COUNTER_NAME = name(HCaptchaClient.class, "invalidReason");
private static final String INVALID_SITEKEY_COUNTER_NAME = name(HCaptchaClient.class, "invalidSiteKey");
private final String apiKey; private final String apiKey;
private final HttpClient client; private final HttpClient client;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager; private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
@ -115,16 +114,15 @@ public class HCaptchaClient implements CaptchaClient {
logger.error("Invalid score {} from hcaptcha response {}", hCaptchaResponse.score, hCaptchaResponse); logger.error("Invalid score {} from hcaptcha response {}", hCaptchaResponse.score, hCaptchaResponse);
return AssessmentResult.invalid(); return AssessmentResult.invalid();
} }
final String scoreString = AssessmentResult.scoreString(score); final BigDecimal threshold = config.getScoreFloorByAction().getOrDefault(action, config.getScoreFloor());
final AssessmentResult assessmentResult = AssessmentResult.fromScore(score, threshold.floatValue());
for (String reason : hCaptchaResponse.scoreReasons) { for (String reason : hCaptchaResponse.scoreReasons) {
Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME, Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME,
"action", action.getActionName(), "action", action.getActionName(),
"reason", reason, "reason", reason,
"score", scoreString).increment(); "score", assessmentResult.getScoreString()).increment();
} }
return assessmentResult;
final BigDecimal threshold = config.getScoreFloorByAction().getOrDefault(action, config.getScoreFloor());
return new AssessmentResult(score >= threshold.floatValue(), scoreString);
} }
} }

View File

@ -113,17 +113,16 @@ public class RecaptchaClient implements CaptchaClient {
if (assessment.getTokenProperties().getValid()) { if (assessment.getTokenProperties().getValid()) {
final float score = assessment.getRiskAnalysis().getScore(); final float score = assessment.getRiskAnalysis().getScore();
log.debug("assessment for {} was valid, score: {}", action.getActionName(), score); log.debug("assessment for {} was valid, score: {}", action.getActionName(), score);
final BigDecimal threshold = config.getScoreFloorByAction().getOrDefault(action, config.getScoreFloor());
final AssessmentResult assessmentResult = AssessmentResult.fromScore(score, threshold.floatValue());
for (RiskAnalysis.ClassificationReason reason : assessment.getRiskAnalysis().getReasonsList()) { for (RiskAnalysis.ClassificationReason reason : assessment.getRiskAnalysis().getReasonsList()) {
Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME, Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME,
"action", action.getActionName(), "action", action.getActionName(),
"score", AssessmentResult.scoreString(score), "score", assessmentResult.getScoreString(),
"reason", reason.name()) "reason", reason.name())
.increment(); .increment();
} }
final BigDecimal threshold = config.getScoreFloorByAction().getOrDefault(action, config.getScoreFloor()); return assessmentResult;
return new AssessmentResult(
score >= threshold.floatValue(),
AssessmentResult.scoreString(score));
} else { } else {
Metrics.counter(INVALID_REASON_COUNTER_NAME, Metrics.counter(INVALID_REASON_COUNTER_NAME,
"action", action.getActionName(), "action", action.getActionName(),

View File

@ -93,7 +93,9 @@ import org.whispersystems.textsecuregcm.push.PushNotificationManager;
import org.whispersystems.textsecuregcm.registration.ClientType; import org.whispersystems.textsecuregcm.registration.ClientType;
import org.whispersystems.textsecuregcm.registration.MessageTransport; import org.whispersystems.textsecuregcm.registration.MessageTransport;
import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
import org.whispersystems.textsecuregcm.spam.Extract;
import org.whispersystems.textsecuregcm.spam.FilterSpam; import org.whispersystems.textsecuregcm.spam.FilterSpam;
import org.whispersystems.textsecuregcm.spam.ScoreThreshold;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ChangeNumberManager; import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
@ -255,14 +257,15 @@ public class AccountController {
@Path("/{transport}/code/{number}") @Path("/{transport}/code/{number}")
@FilterSpam @FilterSpam
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public Response createAccount(@PathParam("transport") String transport, public Response createAccount(@PathParam("transport") String transport,
@PathParam("number") String number, @PathParam("number") String number,
@HeaderParam(HttpHeaders.X_FORWARDED_FOR) String forwardedFor, @HeaderParam(HttpHeaders.X_FORWARDED_FOR) String forwardedFor,
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent, @HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
@HeaderParam(HttpHeaders.ACCEPT_LANGUAGE) Optional<String> acceptLanguage, @HeaderParam(HttpHeaders.ACCEPT_LANGUAGE) Optional<String> acceptLanguage,
@QueryParam("client") Optional<String> client, @QueryParam("client") Optional<String> client,
@QueryParam("captcha") Optional<String> captcha, @QueryParam("captcha") Optional<String> captcha,
@QueryParam("challenge") Optional<String> pushChallenge) @QueryParam("challenge") Optional<String> pushChallenge,
@Extract ScoreThreshold captchaScoreThreshold)
throws RateLimitExceededException, ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException, IOException { throws RateLimitExceededException, ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException, IOException {
Util.requireNormalizedNumber(number); Util.requireNormalizedNumber(number);
@ -278,12 +281,12 @@ public class AccountController {
assessmentResult.ifPresent(result -> assessmentResult.ifPresent(result ->
Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of( Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of(
Tag.of("success", String.valueOf(result.valid())), Tag.of("success", String.valueOf(result.isValid(captchaScoreThreshold.getScoreThreshold()))),
UserAgentTagUtil.getPlatformTag(userAgent), UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, countryCode), Tag.of(COUNTRY_CODE_TAG_NAME, countryCode),
Tag.of(REGION_TAG_NAME, region), Tag.of(REGION_TAG_NAME, region),
Tag.of(REGION_CODE_TAG_NAME, region), Tag.of(REGION_CODE_TAG_NAME, region),
Tag.of(SCORE_TAG_NAME, result.score()))) Tag.of(SCORE_TAG_NAME, result.getScoreString())))
.increment()); .increment());
final boolean pushChallengeMatch = pushChallengeMatches(number, pushChallenge, maybeStoredVerificationCode); final boolean pushChallengeMatch = pushChallengeMatches(number, pushChallenge, maybeStoredVerificationCode);
@ -293,7 +296,7 @@ public class AccountController {
} }
final boolean requiresCaptcha = assessmentResult final boolean requiresCaptcha = assessmentResult
.map(result -> !result.valid()) .map(result -> !result.isValid(captchaScoreThreshold.getScoreThreshold()))
.orElseGet( .orElseGet(
() -> registrationCaptchaManager.requiresCaptcha(number, forwardedFor, sourceHost, pushChallengeMatch)); () -> registrationCaptchaManager.requiresCaptcha(number, forwardedFor, sourceHost, pushChallengeMatch));

View File

@ -75,8 +75,10 @@ import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
import org.whispersystems.textsecuregcm.registration.RegistrationServiceException; import org.whispersystems.textsecuregcm.registration.RegistrationServiceException;
import org.whispersystems.textsecuregcm.registration.RegistrationServiceSenderException; import org.whispersystems.textsecuregcm.registration.RegistrationServiceSenderException;
import org.whispersystems.textsecuregcm.registration.VerificationSession; import org.whispersystems.textsecuregcm.registration.VerificationSession;
import org.whispersystems.textsecuregcm.spam.Extract;
import org.whispersystems.textsecuregcm.spam.FilterSpam; import org.whispersystems.textsecuregcm.spam.FilterSpam;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.spam.ScoreThreshold;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
import org.whispersystems.textsecuregcm.storage.VerificationSessionManager; import org.whispersystems.textsecuregcm.storage.VerificationSessionManager;
import org.whispersystems.textsecuregcm.util.ExceptionUtils; import org.whispersystems.textsecuregcm.util.ExceptionUtils;
@ -89,7 +91,6 @@ import org.whispersystems.textsecuregcm.util.Util;
public class VerificationController { public class VerificationController {
private static final Logger logger = LoggerFactory.getLogger(VerificationController.class); private static final Logger logger = LoggerFactory.getLogger(VerificationController.class);
private static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15); private static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15);
private static final Duration DYNAMODB_TIMEOUT = Duration.ofSeconds(5); private static final Duration DYNAMODB_TIMEOUT = Duration.ofSeconds(5);
@ -195,7 +196,8 @@ public class VerificationController {
public VerificationSessionResponse updateSession(@PathParam("sessionId") final String encodedSessionId, public VerificationSessionResponse updateSession(@PathParam("sessionId") final String encodedSessionId,
@HeaderParam(com.google.common.net.HttpHeaders.X_FORWARDED_FOR) String forwardedFor, @HeaderParam(com.google.common.net.HttpHeaders.X_FORWARDED_FOR) String forwardedFor,
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent, @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
@NotNull @Valid final UpdateVerificationSessionRequest updateVerificationSessionRequest) { @NotNull @Valid final UpdateVerificationSessionRequest updateVerificationSessionRequest,
@NotNull @Extract final ScoreThreshold captchaScoreThreshold) {
final String sourceHost = HeaderUtils.getMostRecentProxy(forwardedFor).orElseThrow(); final String sourceHost = HeaderUtils.getMostRecentProxy(forwardedFor).orElseThrow();
@ -213,7 +215,7 @@ public class VerificationController {
verificationSession); verificationSession);
verificationSession = handleCaptcha(sourceHost, updateVerificationSessionRequest, registrationServiceSession, verificationSession = handleCaptcha(sourceHost, updateVerificationSessionRequest, registrationServiceSession,
verificationSession, userAgent); verificationSession, userAgent, captchaScoreThreshold.getScoreThreshold());
} catch (final RateLimitExceededException e) { } catch (final RateLimitExceededException e) {
final Response response = buildResponseForRateLimitExceeded(verificationSession, registrationServiceSession, final Response response = buildResponseForRateLimitExceeded(verificationSession, registrationServiceSession,
@ -351,11 +353,13 @@ public class VerificationController {
* @throws ForbiddenException if assessment is not valid. * @throws ForbiddenException if assessment is not valid.
* @throws RateLimitExceededException if too many captchas have been submitted * @throws RateLimitExceededException if too many captchas have been submitted
*/ */
private VerificationSession handleCaptcha(final String sourceHost, private VerificationSession handleCaptcha(
final String sourceHost,
final UpdateVerificationSessionRequest updateVerificationSessionRequest, final UpdateVerificationSessionRequest updateVerificationSessionRequest,
final RegistrationServiceSession registrationServiceSession, final RegistrationServiceSession registrationServiceSession,
VerificationSession verificationSession, VerificationSession verificationSession,
final String userAgent) throws RateLimitExceededException { final String userAgent,
final Optional<Float> captchaScoreThreshold) throws RateLimitExceededException {
if (updateVerificationSessionRequest.captcha() == null) { if (updateVerificationSessionRequest.captcha() == null) {
return verificationSession; return verificationSession;
@ -366,23 +370,24 @@ public class VerificationController {
final AssessmentResult assessmentResult; final AssessmentResult assessmentResult;
try { try {
assessmentResult = registrationCaptchaManager.assessCaptcha( assessmentResult = registrationCaptchaManager.assessCaptcha(
Optional.of(updateVerificationSessionRequest.captcha()), sourceHost) Optional.of(updateVerificationSessionRequest.captcha()), sourceHost)
.orElseThrow(() -> new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR)); .orElseThrow(() -> new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR));
Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of( Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of(
Tag.of(SUCCESS_TAG_NAME, String.valueOf(assessmentResult.valid())), Tag.of(SUCCESS_TAG_NAME, String.valueOf(assessmentResult.isValid(captchaScoreThreshold))),
UserAgentTagUtil.getPlatformTag(userAgent), UserAgentTagUtil.getPlatformTag(userAgent),
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(registrationServiceSession.number())), Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(registrationServiceSession.number())),
Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(registrationServiceSession.number())), Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(registrationServiceSession.number())),
Tag.of(SCORE_TAG_NAME, assessmentResult.score()))) Tag.of(SCORE_TAG_NAME, assessmentResult.getScoreString())))
.increment(); .increment();
} catch (IOException e) { } catch (IOException e) {
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE); throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE);
} }
if (assessmentResult.valid()) { if (assessmentResult.isValid(captchaScoreThreshold)) {
final List<VerificationSession.Information> submittedInformation = new ArrayList<>( final List<VerificationSession.Information> submittedInformation = new ArrayList<>(
verificationSession.submittedInformation()); verificationSession.submittedInformation());
submittedInformation.add(VerificationSession.Information.CAPTCHA); submittedInformation.add(VerificationSession.Information.CAPTCHA);

View File

@ -68,7 +68,7 @@ public class RateLimitChallengeManager {
rateLimiters.getRecaptchaChallengeAttemptLimiter().validate(account.getUuid()); rateLimiters.getRecaptchaChallengeAttemptLimiter().validate(account.getUuid());
final boolean challengeSuccess = captchaChecker.verify(Action.CHALLENGE, captcha, mostRecentProxyIp).valid(); final boolean challengeSuccess = captchaChecker.verify(Action.CHALLENGE, captcha, mostRecentProxyIp).isValid();
final Tags tags = Tags.of( final Tags tags = Tags.of(
Tag.of(SOURCE_COUNTRY_TAG_NAME, Util.getCountryCode(account.getNumber())), Tag.of(SOURCE_COUNTRY_TAG_NAME, Util.getCountryCode(account.getNumber())),

View File

@ -0,0 +1,20 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.spam;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Indicates that a parameter should be parsed from a {@link org.glassfish.jersey.server.ContainerRequest}
*/
@Target({ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Extract {
}

View File

@ -0,0 +1,46 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.spam;
import org.glassfish.jersey.server.ContainerRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Optional;
/**
* A ScoreThreshold may be provided by an upstream request filter. If request contains a property for
* SCORE_THRESHOLD_PROPERTY_NAME it can be forwarded to a downstream filter to indicate it can use
* a more or less strict score threshold when evaluating whether a request should be allowed to continue.
*/
public class ScoreThreshold {
private static final Logger logger = LoggerFactory.getLogger(ScoreThreshold.class);
public static final String PROPERTY_NAME = "scoreThreshold";
/**
* A score threshold in the range [0, 1.0]
*/
private final Optional<Float> scoreThreshold;
/**
* Extract an optional score threshold parameter provided by an upstream request filter
*/
public ScoreThreshold(final ContainerRequest containerRequest) {
this.scoreThreshold = Optional
.ofNullable(containerRequest.getProperty(PROPERTY_NAME))
.flatMap(obj -> {
if (obj instanceof Float f) {
return Optional.of(f);
}
logger.warn("invalid format for filter provided score threshold {}", obj);
return Optional.empty();
});
}
public Optional<Float> getScoreThreshold() {
return this.scoreThreshold;
}
}

View File

@ -0,0 +1,55 @@
package org.whispersystems.textsecuregcm.spam;
import java.util.function.Function;
import javax.inject.Singleton;
import javax.ws.rs.core.Feature;
import javax.ws.rs.core.FeatureContext;
import org.glassfish.jersey.internal.inject.AbstractBinder;
import org.glassfish.jersey.server.ContainerRequest;
import org.glassfish.jersey.server.model.Parameter;
import org.glassfish.jersey.server.spi.internal.ValueParamProvider;
/**
* Parses a {@link ScoreThreshold} out of a {@link ContainerRequest} to provide to jersey resources.
*
* A request filter may enrich a ContainerRequest with a scoreThreshold by providing a float property with the name
* {@link ScoreThreshold#PROPERTY_NAME}. This indicates the desired scoreThreshold to use when evaluating whether a
* request should proceed.
*
* A resource can consume a ScoreThreshold with by annotating a ScoreThreshold parameter with {@link Extract}
*/
public class ScoreThresholdProvider implements ValueParamProvider {
/**
* Configures the ScoreThresholdProvider
*/
public static class ScoreThresholdFeature implements Feature {
@Override
public boolean configure(FeatureContext context) {
context.register(new AbstractBinder() {
@Override
protected void configure() {
bind(ScoreThresholdProvider.class)
.to(ValueParamProvider.class)
.in(Singleton.class);
}
});
return true;
}
}
@Override
public Function<ContainerRequest, ?> getValueProvider(final Parameter parameter) {
if (parameter.getRawType().equals(ScoreThreshold.class)
&& parameter.isAnnotationPresent(Extract.class)) {
return ScoreThreshold::new;
}
return null;
}
@Override
public PriorityType getPriority() {
return Priority.HIGH;
}
}

View File

@ -84,7 +84,7 @@ public class CaptchaCheckerTest {
@ParameterizedTest @ParameterizedTest
@MethodSource @MethodSource
void scoreString(float score, String expected) { void scoreString(float score, String expected) {
assertThat(AssessmentResult.scoreString(score)).isEqualTo(expected); assertThat(AssessmentResult.fromScore(score, 0.0f).getScoreString()).isEqualTo(expected);
} }

View File

@ -58,8 +58,7 @@ public class HCaptchaClientTest {
if (!success) { if (!success) {
assertThat(result).isEqualTo(AssessmentResult.invalid()); assertThat(result).isEqualTo(AssessmentResult.invalid());
} else { } else {
assertThat(result) assertThat(result.isValid()).isEqualTo(expectedResult);
.isEqualTo(new AssessmentResult(expectedResult, AssessmentResult.scoreString(score)));
} }
} }

View File

@ -109,6 +109,8 @@ import org.whispersystems.textsecuregcm.push.PushNotificationManager;
import org.whispersystems.textsecuregcm.registration.ClientType; import org.whispersystems.textsecuregcm.registration.ClientType;
import org.whispersystems.textsecuregcm.registration.MessageTransport; import org.whispersystems.textsecuregcm.registration.MessageTransport;
import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;
import org.whispersystems.textsecuregcm.spam.Extract;
import org.whispersystems.textsecuregcm.spam.ScoreThresholdProvider;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.ChangeNumberManager; import org.whispersystems.textsecuregcm.storage.ChangeNumberManager;
@ -215,6 +217,7 @@ class AccountControllerTest {
.addProvider(new ImpossiblePhoneNumberExceptionMapper()) .addProvider(new ImpossiblePhoneNumberExceptionMapper())
.addProvider(new NonNormalizedPhoneNumberExceptionMapper()) .addProvider(new NonNormalizedPhoneNumberExceptionMapper())
.addProvider(new RateLimitByIpFilter(rateLimiters)) .addProvider(new RateLimitByIpFilter(rateLimiters))
.addProvider(ScoreThresholdProvider.ScoreThresholdFeature.class)
.setMapper(SystemMapper.jsonMapper()) .setMapper(SystemMapper.jsonMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory()) .setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new AccountController(pendingAccountsManager, .addResource(new AccountController(pendingAccountsManager,
@ -349,7 +352,7 @@ class AccountControllerTest {
when(captchaChecker.verify(eq(Action.REGISTRATION), eq(INVALID_CAPTCHA_TOKEN), anyString())) when(captchaChecker.verify(eq(Action.REGISTRATION), eq(INVALID_CAPTCHA_TOKEN), anyString()))
.thenReturn(AssessmentResult.invalid()); .thenReturn(AssessmentResult.invalid());
when(captchaChecker.verify(eq(Action.REGISTRATION), eq(VALID_CAPTCHA_TOKEN), anyString())) when(captchaChecker.verify(eq(Action.REGISTRATION), eq(VALID_CAPTCHA_TOKEN), anyString()))
.thenReturn(new AssessmentResult(true, "")); .thenReturn(AssessmentResult.alwaysValid());
doThrow(new RateLimitExceededException(Duration.ZERO, true)).when(pinLimiter).validate(eq(SENDER_OVER_PIN)); doThrow(new RateLimitExceededException(Duration.ZERO, true)).when(pinLimiter).validate(eq(SENDER_OVER_PIN));
@ -690,6 +693,7 @@ class AccountControllerTest {
final Response response = final Response response =
resources.getJerseyTest() resources.getJerseyTest()
.target(String.format("/v1/accounts/sms/code/%s", number)) .target(String.format("/v1/accounts/sms/code/%s", number))
.register(Extract.class)
.queryParam("challenge", "1234-push") .queryParam("challenge", "1234-push")
.request() .request()
.header(HttpHeaders.X_FORWARDED_FOR, NICE_HOST) .header(HttpHeaders.X_FORWARDED_FOR, NICE_HOST)

View File

@ -69,6 +69,7 @@ import org.whispersystems.textsecuregcm.registration.RegistrationServiceSenderEx
import org.whispersystems.textsecuregcm.registration.VerificationSession; import org.whispersystems.textsecuregcm.registration.VerificationSession;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.spam.ScoreThresholdProvider;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
import org.whispersystems.textsecuregcm.storage.VerificationSessionManager; import org.whispersystems.textsecuregcm.storage.VerificationSessionManager;
import org.whispersystems.textsecuregcm.util.SystemMapper; import org.whispersystems.textsecuregcm.util.SystemMapper;
@ -100,6 +101,7 @@ class VerificationControllerTest {
.addProvider(new ImpossiblePhoneNumberExceptionMapper()) .addProvider(new ImpossiblePhoneNumberExceptionMapper())
.addProvider(new NonNormalizedPhoneNumberExceptionMapper()) .addProvider(new NonNormalizedPhoneNumberExceptionMapper())
.addProvider(new RegistrationServiceSenderExceptionMapper()) .addProvider(new RegistrationServiceSenderExceptionMapper())
.addProvider(ScoreThresholdProvider.ScoreThresholdFeature.class)
.setMapper(SystemMapper.jsonMapper()) .setMapper(SystemMapper.jsonMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory()) .setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource( .addResource(
@ -621,7 +623,7 @@ class VerificationControllerTest {
registrationServiceSession.expiration())))); registrationServiceSession.expiration()))));
when(registrationCaptchaManager.assessCaptcha(any(), any())) when(registrationCaptchaManager.assessCaptcha(any(), any()))
.thenReturn(Optional.of(new AssessmentResult(true, "1"))); .thenReturn(Optional.of(AssessmentResult.alwaysValid()));
when(verificationSessionManager.update(any(), any())) when(verificationSessionManager.update(any(), any()))
.thenReturn(CompletableFuture.completedFuture(null)); .thenReturn(CompletableFuture.completedFuture(null));
@ -669,7 +671,7 @@ class VerificationControllerTest {
registrationServiceSession.expiration())))); registrationServiceSession.expiration()))));
when(registrationCaptchaManager.assessCaptcha(any(), any())) when(registrationCaptchaManager.assessCaptcha(any(), any()))
.thenReturn(Optional.of(new AssessmentResult(true, "1"))); .thenReturn(Optional.of(AssessmentResult.alwaysValid()));
when(verificationSessionManager.update(any(), any())) when(verificationSessionManager.update(any(), any()))
.thenReturn(CompletableFuture.completedFuture(null)); .thenReturn(CompletableFuture.completedFuture(null));

View File

@ -78,7 +78,7 @@ class RateLimitChallengeManagerTest {
when(captchaChecker.verify(eq(Action.CHALLENGE), any(), any())) when(captchaChecker.verify(eq(Action.CHALLENGE), any(), any()))
.thenReturn(successfulChallenge .thenReturn(successfulChallenge
? new AssessmentResult(true, "") ? AssessmentResult.alwaysValid()
: AssessmentResult.invalid()); : AssessmentResult.invalid());
when(rateLimiters.getRecaptchaChallengeAttemptLimiter()).thenReturn(mock(RateLimiter.class)); when(rateLimiters.getRecaptchaChallengeAttemptLimiter()).thenReturn(mock(RateLimiter.class));