Add per-action captcha site-key configuration

- reject captcha requests without valid actions
- require specific site keys for each action
This commit is contained in:
Ravi Khadiwala
2023-03-13 09:59:03 -05:00
committed by ravi-signal
parent fd8918eaff
commit a8eb27940d
13 changed files with 281 additions and 89 deletions

View File

@@ -0,0 +1,43 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.captcha;
import com.fasterxml.jackson.annotation.JsonCreator;
import java.util.Arrays;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
public enum Action {
CHALLENGE("challenge"),
REGISTRATION("registration");
private final String actionName;
Action(String actionName) {
this.actionName = actionName;
}
public String getActionName() {
return actionName;
}
private static final Map<String, Action> ENUM_MAP = Arrays
.stream(Action.values())
.collect(Collectors.toMap(
a -> a.actionName,
Function.identity()));
@JsonCreator
public static Action fromString(String key) {
return ENUM_MAP.get(key.toLowerCase(Locale.ROOT).strip());
}
static Optional<Action> parse(final String action) {
return Optional.ofNullable(fromString(action));
}
}

View File

@@ -5,19 +5,26 @@
package org.whispersystems.textsecuregcm.captcha;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import com.google.common.annotations.VisibleForTesting;
import io.micrometer.core.instrument.Metrics;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import javax.ws.rs.BadRequestException;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class CaptchaChecker {
private static final Logger logger = LoggerFactory.getLogger(CaptchaChecker.class);
private static final String INVALID_SITEKEY_COUNTER_NAME = name(CaptchaChecker.class, "invalidSiteKey");
private static final String ASSESSMENTS_COUNTER_NAME = name(RecaptchaClient.class, "assessments");
private static final String INVALID_ACTION_COUNTER_NAME = name(CaptchaChecker.class, "invalidActions");
@VisibleForTesting
static final String SEPARATOR = ".";
@@ -29,44 +36,61 @@ public class CaptchaChecker {
.collect(Collectors.toMap(CaptchaClient::scheme, Function.identity()));
}
/**
* Check if a solved captcha should be accepted
* <p>
*
* @param input expected to contain a prefix indicating the captcha scheme, sitekey, token, and action. The expected
* format is {@code version-prefix.sitekey.[action.]token}
* @param ip IP of the solver
* @param expectedAction the {@link Action} for which this captcha solution is intended
* @param input expected to contain a prefix indicating the captcha scheme, sitekey, token, and action. The
* expected format is {@code version-prefix.sitekey.action.token}
* @param ip IP of the solver
* @return An {@link AssessmentResult} indicating whether the solution should be accepted, and a score that can be
* used for metrics
* @throws IOException if there is an error validating the captcha with the underlying service
* @throws BadRequestException if input is not in the expected format
*/
public AssessmentResult verify(final String input, final String ip) throws IOException {
/*
* For action to be optional, there is a strong assumption that the token will never contain a {@value SEPARATOR}.
* Observation suggests {@code token} is base-64 encoded. In practice, an action should always be present, but we
* dont need to be strict.
*/
public AssessmentResult verify(
final Action expectedAction,
final String input,
final String ip) throws IOException {
final String[] parts = input.split("\\" + SEPARATOR, 4);
// we allow missing actions, if we're missing 1 part, assume it's the action
if (parts.length < 3) {
if (parts.length < 4) {
throw new BadRequestException("too few parts");
}
int idx = 0;
final String prefix = parts[idx++];
final String siteKey = parts[idx++];
final String action = parts.length == 3 ? null : parts[idx++];
final String token = parts[idx];
final String prefix = parts[0];
final String siteKey = parts[1].toLowerCase(Locale.ROOT).strip();
final String action = parts[2];
final String token = parts[3];
final CaptchaClient client = this.captchaClientMap.get(prefix);
if (client == null) {
throw new BadRequestException("invalid captcha scheme");
}
final AssessmentResult result = client.verify(siteKey, action, token, ip);
final Action parsedAction = Action.parse(action)
.orElseThrow(() -> {
Metrics.counter(INVALID_ACTION_COUNTER_NAME, "action", action).increment();
throw new BadRequestException("invalid captcha action");
});
if (!parsedAction.equals(expectedAction)) {
Metrics.counter(INVALID_ACTION_COUNTER_NAME, "action", action).increment();
throw new BadRequestException("invalid captcha action");
}
final Set<String> allowedSiteKeys = client.validSiteKeys(parsedAction);
if (!allowedSiteKeys.contains(siteKey)) {
logger.debug("invalid site-key {}, action={}, token={}", siteKey, action, token);
Metrics.counter(INVALID_SITEKEY_COUNTER_NAME, "action", action).increment();
throw new BadRequestException("invalid captcha site-key");
}
final AssessmentResult result = client.verify(siteKey, parsedAction, token, ip);
Metrics.counter(ASSESSMENTS_COUNTER_NAME,
"action", String.valueOf(action),
"action", action,
"valid", String.valueOf(result.valid()),
"score", result.score(),
"provider", prefix)

View File

@@ -5,29 +5,37 @@
package org.whispersystems.textsecuregcm.captcha;
import javax.annotation.Nullable;
import java.io.IOException;
import java.util.Optional;
import java.util.Set;
public interface CaptchaClient {
/**
* @return the identifying captcha scheme that this CaptchaClient handles
*/
String scheme();
/**
* @param action the action to retrieve site keys for
* @return siteKeys this client is willing to accept
*/
Set<String> validSiteKeys(final Action action);
/**
* Verify a provided captcha solution
*
* @param siteKey identifying string for the captcha service
* @param action an optional action indicating the purpose of the captcha
* @param action an action indicating the purpose of the captcha
* @param token the captcha solution that will be verified
* @param ip the ip of the captcha solve
* @param ip the ip of the captcha solver
* @return An {@link AssessmentResult} indicating whether the solution should be accepted
* @throws IOException if the underlying captcha provider returns an error
*/
AssessmentResult verify(
final String siteKey,
final @Nullable String action,
final Action action,
final String token,
final String ip) throws IOException;
}

View File

@@ -16,7 +16,9 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import javax.annotation.Nullable;
import java.util.Collections;
import java.util.Optional;
import java.util.Set;
import javax.ws.rs.core.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -31,6 +33,7 @@ public class HCaptchaClient implements CaptchaClient {
private static final String PREFIX = "signal-hcaptcha";
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_SITEKEY_COUNTER_NAME = name(HCaptchaClient.class, "invalidSiteKey");
private final String apiKey;
private final HttpClient client;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
@@ -50,21 +53,31 @@ public class HCaptchaClient implements CaptchaClient {
}
@Override
public AssessmentResult verify(final String siteKey, final @Nullable String action, final String token,
public Set<String> validSiteKeys(final Action action) {
final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration();
if (!config.isAllowHCaptcha()) {
logger.warn("Received request to verify an hCaptcha, but hCaptcha is not enabled");
return Collections.emptySet();
}
return Optional
.ofNullable(config.getHCaptchaSiteKeys().get(action))
.orElse(Collections.emptySet());
}
@Override
public AssessmentResult verify(
final String siteKey,
final Action action,
final String token,
final String ip)
throws IOException {
final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration();
if (!config.isAllowHCaptcha()) {
logger.warn("Received request to verify an hCaptcha, but hCaptcha is not enabled");
return AssessmentResult.invalid();
}
final String body = String.format("response=%s&secret=%s&remoteip=%s",
URLEncoder.encode(token, StandardCharsets.UTF_8),
URLEncoder.encode(this.apiKey, StandardCharsets.UTF_8),
ip);
HttpRequest request = HttpRequest.newBuilder()
final HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://hcaptcha.com/siteverify"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body))
@@ -90,14 +103,14 @@ public class HCaptchaClient implements CaptchaClient {
if (!hCaptchaResponse.success) {
for (String errorCode : hCaptchaResponse.errorCodes) {
Metrics.counter(INVALID_REASON_COUNTER_NAME,
"action", String.valueOf(action),
"action", action.getActionName(),
"reason", errorCode).increment();
}
return AssessmentResult.invalid();
}
// hcaptcha uses the inverse scheme of recaptcha (for hcaptcha, a low score is less risky)
float score = 1.0f - hCaptchaResponse.score;
final float score = 1.0f - hCaptchaResponse.score;
if (score < 0.0f || score > 1.0f) {
logger.error("Invalid score {} from hcaptcha response {}", hCaptchaResponse.score, hCaptchaResponse);
return AssessmentResult.invalid();
@@ -106,7 +119,7 @@ public class HCaptchaClient implements CaptchaClient {
for (String reason : hCaptchaResponse.scoreReasons) {
Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME,
"action", String.valueOf(action),
"action", action.getActionName(),
"reason", reason,
"score", scoreString).increment();
}

View File

@@ -20,9 +20,11 @@ import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration;
@@ -35,8 +37,10 @@ public class RecaptchaClient implements CaptchaClient {
private static final String V2_PREFIX = "signal-recaptcha-v2";
private static final String INVALID_REASON_COUNTER_NAME = name(RecaptchaClient.class, "invalidReason");
private static final String INVALID_SITEKEY_COUNTER_NAME = name(RecaptchaClient.class, "invalidSiteKey");
private static final String ASSESSMENT_REASON_COUNTER_NAME = name(RecaptchaClient.class, "assessmentReason");
private final String projectPath;
private final RecaptchaEnterpriseServiceClient client;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
@@ -64,12 +68,28 @@ public class RecaptchaClient implements CaptchaClient {
}
@Override
public org.whispersystems.textsecuregcm.captcha.AssessmentResult verify(final String sitekey,
final @Nullable String expectedAction,
final String token, final String ip) throws IOException {
public Set<String> validSiteKeys(final Action action) {
final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration();
if (!config.isAllowRecaptcha()) {
log.warn("Received request to verify a recaptcha, but recaptcha is not enabled");
return Collections.emptySet();
}
return Optional
.ofNullable(config.getRecaptchaSiteKeys().get(action))
.orElse(Collections.emptySet());
}
@Override
public org.whispersystems.textsecuregcm.captcha.AssessmentResult verify(
final String sitekey,
final Action action,
final String token,
final String ip) throws IOException {
final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration();
final Set<String> allowedSiteKeys = config.getRecaptchaSiteKeys().get(action);
if (allowedSiteKeys != null && !allowedSiteKeys.contains(sitekey)) {
log.info("invalid recaptcha sitekey {}, action={}, token={}", action, token);
Metrics.counter(INVALID_SITEKEY_COUNTER_NAME, "action", action.getActionName()).increment();
return AssessmentResult.invalid();
}
@@ -78,8 +98,8 @@ public class RecaptchaClient implements CaptchaClient {
.setToken(token)
.setUserIpAddress(ip);
if (expectedAction != null) {
eventBuilder.setExpectedAction(expectedAction);
if (action != null) {
eventBuilder.setExpectedAction(action.getActionName());
}
final Event event = eventBuilder.build();
@@ -92,21 +112,21 @@ public class RecaptchaClient implements CaptchaClient {
if (assessment.getTokenProperties().getValid()) {
final float score = assessment.getRiskAnalysis().getScore();
log.debug("assessment for {} was valid, score: {}", expectedAction, score);
log.debug("assessment for {} was valid, score: {}", action.getActionName(), score);
for (RiskAnalysis.ClassificationReason reason : assessment.getRiskAnalysis().getReasonsList()) {
Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME,
"action", String.valueOf(expectedAction),
"action", action.getActionName(),
"score", AssessmentResult.scoreString(score),
"reason", reason.name())
.increment();
}
final BigDecimal threshold = config.getScoreFloorByAction().getOrDefault(expectedAction, config.getScoreFloor());
final BigDecimal threshold = config.getScoreFloorByAction().getOrDefault(action, config.getScoreFloor());
return new AssessmentResult(
score >= threshold.floatValue(),
AssessmentResult.scoreString(score));
} else {
Metrics.counter(INVALID_REASON_COUNTER_NAME,
"action", String.valueOf(expectedAction),
"action", action.getActionName(),
"reason", assessment.getTokenProperties().getInvalidReason().name())
.increment();
return AssessmentResult.invalid();

View File

@@ -54,7 +54,7 @@ public class RegistrationCaptchaManager {
public Optional<AssessmentResult> assessCaptcha(final Optional<String> captcha, final String sourceHost)
throws IOException {
return captcha.isPresent()
? Optional.of(captchaChecker.verify(captcha.get(), sourceHost))
? Optional.of(captchaChecker.verify(Action.REGISTRATION, captcha.get(), sourceHost))
: Optional.empty();
}