From 3ac7aba6b262e30a1e780e3f30221431b2ae8f28 Mon Sep 17 00:00:00 2001 From: Ravi Khadiwala Date: Fri, 7 Jul 2023 10:55:50 -0500 Subject: [PATCH] Add a captcha short-code expander --- service/config/sample.yml | 3 ++ .../WhisperServerConfiguration.java | 10 ++++ .../textsecuregcm/WhisperServerService.java | 6 ++- .../textsecuregcm/captcha/CaptchaChecker.java | 22 +++++++-- .../captcha/ShortCodeExpander.java | 49 +++++++++++++++++++ .../ShortCodeExpanderConfiguration.java | 9 ++++ .../captcha/CaptchaCheckerTest.java | 20 ++++++-- 7 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/captcha/ShortCodeExpander.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/configuration/ShortCodeExpanderConfiguration.java diff --git a/service/config/sample.yml b/service/config/sample.yml index d7906c676..f2b47e9d3 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -243,6 +243,9 @@ recaptcha: hCaptcha: apiKey: secret://hCaptcha.apiKey +shortCode: + baseUrl: https://example.com/shortcodes/ + storageService: uri: storage.example.com userAuthenticationTokenSharedSecret: secret://storageService.userAuthenticationTokenSharedSecret diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index 5d0731a1d..d3279dc6e 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -50,6 +50,7 @@ import org.whispersystems.textsecuregcm.configuration.ReportMessageConfiguration import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration; import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration; import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration; +import org.whispersystems.textsecuregcm.configuration.ShortCodeExpanderConfiguration; import org.whispersystems.textsecuregcm.configuration.SpamFilterConfiguration; import org.whispersystems.textsecuregcm.configuration.StripeConfiguration; import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration; @@ -202,6 +203,11 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private HCaptchaConfiguration hCaptcha; + @Valid + @NotNull + @JsonProperty + private ShortCodeExpanderConfiguration shortCode; + @Valid @NotNull @JsonProperty @@ -334,6 +340,10 @@ public class WhisperServerConfiguration extends Configuration { return hCaptcha; } + public ShortCodeExpanderConfiguration getShortCodeRetrieverConfiguration() { + return shortCode; + } + public WebSocketConfiguration getWebSocketConfiguration() { return webSocket; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 57b369690..08811d459 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -83,6 +83,7 @@ import org.whispersystems.textsecuregcm.captcha.CaptchaChecker; import org.whispersystems.textsecuregcm.captcha.HCaptchaClient; import org.whispersystems.textsecuregcm.captcha.RecaptchaClient; import org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager; +import org.whispersystems.textsecuregcm.captcha.ShortCodeExpander; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; import org.whispersystems.textsecuregcm.configuration.secrets.SecretStore; import org.whispersystems.textsecuregcm.configuration.secrets.SecretsModule; @@ -581,7 +582,10 @@ public class WhisperServerService extends Application captchaClientMap; - public CaptchaChecker(final List captchaClients) { + public CaptchaChecker( + final ShortCodeExpander shortCodeRetriever, + final List captchaClients) { + this.shortCodeExpander = shortCodeRetriever; this.captchaClientMap = captchaClients.stream() .collect(Collectors.toMap(CaptchaClient::scheme, Function.identity())); } @@ -63,9 +69,17 @@ public class CaptchaChecker { final String prefix = parts[0]; final String siteKey = parts[1].toLowerCase(Locale.ROOT).strip(); final String action = parts[2]; - final String token = parts[3]; + String token = parts[3]; - final CaptchaClient client = this.captchaClientMap.get(prefix); + String provider = prefix; + if (prefix.endsWith(SHORT_SUFFIX)) { + // This is a "short" solution that points to the actual solution. We need to fetch the + // full solution before proceeding + provider = prefix.substring(0, prefix.length() - SHORT_SUFFIX.length()); + token = shortCodeExpander.retrieve(token).orElseThrow(() -> new BadRequestException("invalid shortcode")); + } + + final CaptchaClient client = this.captchaClientMap.get(provider); if (client == null) { throw new BadRequestException("invalid captcha scheme"); } @@ -92,7 +106,7 @@ public class CaptchaChecker { Metrics.counter(ASSESSMENTS_COUNTER_NAME, "action", action, "score", result.getScoreString(), - "provider", prefix) + "provider", provider) .increment(); return result; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/ShortCodeExpander.java b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/ShortCodeExpander.java new file mode 100644 index 000000000..ae867970c --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/ShortCodeExpander.java @@ -0,0 +1,49 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.captcha; + +import io.micrometer.core.instrument.Metrics; +import org.apache.http.HttpStatus; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Optional; + +import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; + +public class ShortCodeExpander { + private static final String EXPAND_COUNTER_NAME = name(ShortCodeExpander.class, "expand"); + + private final HttpClient client; + private final URI shortenerHost; + + public ShortCodeExpander(final HttpClient client, final String shortenerHost) { + this.client = client; + this.shortenerHost = URI.create(shortenerHost); + } + + public Optional retrieve(final String shortCode) throws IOException { + final URI uri = shortenerHost.resolve("/" + shortCode); + final HttpRequest request = HttpRequest.newBuilder().uri(uri).GET().build(); + + try { + final HttpResponse response = this.client.send(request, HttpResponse.BodyHandlers.ofString()); + Metrics.counter(EXPAND_COUNTER_NAME, "responseCode", Integer.toString(response.statusCode())).increment(); + return switch (response.statusCode()) { + case HttpStatus.SC_OK -> Optional.of(response.body()); + case HttpStatus.SC_NOT_FOUND -> Optional.empty(); + default -> throw new IOException("Failed to look up shortcode"); + }; + } catch (InterruptedException e) { + throw new IOException(e); + } + } + + + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ShortCodeExpanderConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ShortCodeExpanderConfiguration.java new file mode 100644 index 000000000..918fdd9cb --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ShortCodeExpanderConfiguration.java @@ -0,0 +1,9 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.configuration; + +public record ShortCodeExpanderConfiguration(String baseUrl) { +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/captcha/CaptchaCheckerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/captcha/CaptchaCheckerTest.java index 31df41847..c61d9d866 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/captcha/CaptchaCheckerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/captcha/CaptchaCheckerTest.java @@ -18,6 +18,7 @@ import static org.whispersystems.textsecuregcm.captcha.CaptchaChecker.SEPARATOR; import java.io.IOException; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.stream.Stream; import javax.ws.rs.BadRequestException; import org.junit.jupiter.api.Test; @@ -77,7 +78,7 @@ public class CaptchaCheckerTest { final String siteKey, final Action expectedAction) throws IOException { final CaptchaClient captchaClient = mockClient(PREFIX); - new CaptchaChecker(List.of(captchaClient)).verify(expectedAction, input, null); + new CaptchaChecker(null, List.of(captchaClient)).verify(expectedAction, input, null); verify(captchaClient, times(1)).verify(eq(siteKey), eq(expectedAction), eq(expectedToken), any()); } @@ -105,10 +106,10 @@ public class CaptchaCheckerTest { final CaptchaClient a = mockClient(PREFIX_A); final CaptchaClient b = mockClient(PREFIX_B); - new CaptchaChecker(List.of(a, b)).verify(Action.CHALLENGE, ainput, null); + new CaptchaChecker(null, List.of(a, b)).verify(Action.CHALLENGE, ainput, null); verify(a, times(1)).verify(any(), any(), any(), any()); - new CaptchaChecker(List.of(a, b)).verify(Action.CHALLENGE, binput, null); + new CaptchaChecker(null, List.of(a, b)).verify(Action.CHALLENGE, binput, null); verify(b, times(1)).verify(any(), any(), any(), any()); } @@ -130,7 +131,18 @@ public class CaptchaCheckerTest { public void badArgs(final String input) throws IOException { final CaptchaClient cc = mockClient(PREFIX); assertThrows(BadRequestException.class, - () -> new CaptchaChecker(List.of(cc)).verify(Action.CHALLENGE, input, null)); + () -> new CaptchaChecker(null, List.of(cc)).verify(Action.CHALLENGE, input, null)); + + } + + @Test + public void testShortened() throws IOException { + final CaptchaClient captchaClient = mockClient(PREFIX); + final ShortCodeExpander retriever = mock(ShortCodeExpander.class); + when(retriever.retrieve("abc")).thenReturn(Optional.of(TOKEN)); + final String input = String.join(SEPARATOR, PREFIX + "-short", REG_SITE_KEY, "registration", "abc"); + new CaptchaChecker(retriever, List.of(captchaClient)).verify(Action.REGISTRATION, input, null); + verify(captchaClient, times(1)).verify(eq(REG_SITE_KEY), eq(Action.REGISTRATION), eq(TOKEN), any()); } }