diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index f96108adf..18dec2bf8 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -141,6 +141,7 @@ import org.whispersystems.textsecuregcm.filters.ExternalRequestFilter; import org.whispersystems.textsecuregcm.filters.RemoteAddressFilter; import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter; import org.whispersystems.textsecuregcm.filters.RequestStatisticsFilter; +import org.whispersystems.textsecuregcm.filters.RestDeprecationFilter; import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter; import org.whispersystems.textsecuregcm.geo.MaxMindDatabaseManager; import org.whispersystems.textsecuregcm.grpc.AccountsAnonymousGrpcService; @@ -1001,7 +1002,12 @@ public class WhisperServerService extends Application svrStatusCodesToIgnoreForAccountDeletion = Collections.emptyList(); + @JsonProperty + @Valid + Map minimumRestFreeVersion = Map.of(); + public Optional getExperimentEnrollmentConfiguration( final String experimentName) { return Optional.ofNullable(experiments.get(experimentName)); @@ -130,4 +136,8 @@ public class DynamicConfiguration { return svrStatusCodesToIgnoreForAccountDeletion; } + public Map minimumRestFreeVersion() { + return minimumRestFreeVersion; + } + } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/filters/RestDeprecationFilter.java b/service/src/main/java/org/whispersystems/textsecuregcm/filters/RestDeprecationFilter.java new file mode 100644 index 000000000..cf363960d --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/filters/RestDeprecationFilter.java @@ -0,0 +1,88 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.filters; + +import com.google.common.net.HttpHeaders; +import com.vdurmont.semver4j.Semver; +import io.micrometer.core.instrument.Metrics; +import io.micrometer.core.instrument.Tags; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.core.SecurityContext; +import java.io.IOException; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; +import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; +import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; +import org.whispersystems.textsecuregcm.metrics.MetricsUtil; +import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; +import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; +import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; +import org.whispersystems.textsecuregcm.util.ua.UserAgent; +import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil; + +public class RestDeprecationFilter implements ContainerRequestFilter { + + private static final String EXPERIMENT_NAME = "restDeprecation"; + private static final String DEPRECATED_REST_COUNTER_NAME = MetricsUtil.name(RestDeprecationFilter.class, "blockedRestRequest"); + + private static final Logger log = LoggerFactory.getLogger(RestDeprecationFilter.class); + + final DynamicConfigurationManager dynamicConfigurationManager; + final ExperimentEnrollmentManager experimentEnrollmentManager; + + public RestDeprecationFilter( + final DynamicConfigurationManager dynamicConfigurationManager, + final ExperimentEnrollmentManager experimentEnrollmentManager) { + this.dynamicConfigurationManager = dynamicConfigurationManager; + this.experimentEnrollmentManager = experimentEnrollmentManager; + } + + @Override + public void filter(final ContainerRequestContext requestContext) throws IOException { + + final SecurityContext securityContext = requestContext.getSecurityContext(); + + if (securityContext == null || securityContext.getUserPrincipal() == null) { + // We can't check if an unauthenticated request is in the experiment + return; + } + + if (securityContext.getUserPrincipal() instanceof AuthenticatedDevice ad) { + if (!experimentEnrollmentManager.isEnrolled(ad.getAccount().getUuid(), EXPERIMENT_NAME)) { + return; + } + } else { + log.error("Security context was not null but user principal was of type {}", securityContext.getUserPrincipal().getClass().getName()); + return; + } + + final Map minimumRestFreeVersion = dynamicConfigurationManager.getConfiguration().minimumRestFreeVersion(); + final String userAgentString = requestContext.getHeaderString(HttpHeaders.USER_AGENT); + + try { + final UserAgent userAgent = UserAgentUtil.parseUserAgentString(userAgentString); + final ClientPlatform platform = userAgent.getPlatform(); + final Semver version = userAgent.getVersion(); + if (!minimumRestFreeVersion.containsKey(platform)) { + return; + } + if (version.isGreaterThanOrEqualTo(minimumRestFreeVersion.get(platform))) { + Metrics.counter( + DEPRECATED_REST_COUNTER_NAME, Tags.of("platform", platform.name().toLowerCase(), "version", version.toString())) + .increment(); + throw new WebApplicationException("use websockets", 498); + } + } catch (final UnrecognizedUserAgentException e) { + return; // at present we're only interested in experimenting on known clients + } + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/filters/RestDeprecationFilterTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/filters/RestDeprecationFilterTest.java new file mode 100644 index 000000000..707d21a69 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/filters/RestDeprecationFilterTest.java @@ -0,0 +1,111 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.filters; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.common.net.HttpHeaders; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.SecurityContext; +import java.net.URI; +import java.util.UUID; +import org.glassfish.jersey.server.ContainerRequest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; +import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; +import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; +import org.whispersystems.textsecuregcm.tests.util.FakeDynamicConfigurationManager; +import org.whispersystems.textsecuregcm.util.SystemMapper; + +class RestDeprecationFilterTest { + + @Test + void testNoConfig() throws Exception { + final DynamicConfigurationManager dynamicConfigurationManager = + new FakeDynamicConfigurationManager<>(new DynamicConfiguration()); + final ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager); + + final RestDeprecationFilter filter = new RestDeprecationFilter(dynamicConfigurationManager, experimentEnrollmentManager); + + final Account account = new Account(); + account.setUuid(UUID.randomUUID()); + final SecurityContext securityContext = mock(SecurityContext.class); + when(securityContext.getUserPrincipal()).thenReturn(new AuthenticatedDevice(account, new Device())); + final ContainerRequest req = new ContainerRequest(null, new URI("/some/uri"), "GET", securityContext, null, null); + req.getHeaders().add(HttpHeaders.USER_AGENT, "Signal-Android/100.0.0"); + + filter.filter(req); + } + + @Test + void testOldClient() throws Exception { + final DynamicConfiguration config = SystemMapper.yamlMapper().readValue( + """ + minimumRestFreeVersion: + ANDROID: 200.0.0 + experiments: + restDeprecation: + uuidEnrollmentPercentage: 100 + """, + DynamicConfiguration.class); + final DynamicConfigurationManager dynamicConfigurationManager = new FakeDynamicConfigurationManager<>(config); + final ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager); + + final RestDeprecationFilter filter = new RestDeprecationFilter(dynamicConfigurationManager, experimentEnrollmentManager); + + final Account account = new Account(); + account.setUuid(UUID.randomUUID()); + final SecurityContext securityContext = mock(SecurityContext.class); + when(securityContext.getUserPrincipal()).thenReturn(new AuthenticatedDevice(account, new Device())); + final ContainerRequest req = new ContainerRequest(null, new URI("/some/uri"), "GET", securityContext, null, null); + req.getHeaders().add(HttpHeaders.USER_AGENT, "Signal-Android/100.0.0"); + + filter.filter(req); + } + + @Test + void testBlocking() throws Exception { + final DynamicConfiguration config = SystemMapper.yamlMapper().readValue( + """ + minimumRestFreeVersion: + ANDROID: 10.10.10 + experiments: + restDeprecation: + enrollmentPercentage: 100 + """, + DynamicConfiguration.class); + final DynamicConfigurationManager dynamicConfigurationManager = new FakeDynamicConfigurationManager<>(config); + final ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager); + + final RestDeprecationFilter filter = new RestDeprecationFilter(dynamicConfigurationManager, experimentEnrollmentManager); + + final Account account = new Account(); + account.setUuid(UUID.randomUUID()); + final SecurityContext securityContext = mock(SecurityContext.class); + when(securityContext.getUserPrincipal()).thenReturn(new AuthenticatedDevice(account, new Device())); + final ContainerRequest req = new ContainerRequest(null, new URI("/some/path"), "GET", securityContext, null, null); + + req.getHeaders().putSingle(HttpHeaders.USER_AGENT, "Signal-Android/10.9.15"); + filter.filter(req); + + req.getHeaders().putSingle(HttpHeaders.USER_AGENT, "Signal-Android/10.10.9"); + filter.filter(req); + + req.getHeaders().putSingle(HttpHeaders.USER_AGENT, "Signal-Android/10.10.10"); + assertThrows(WebApplicationException.class, () -> filter.filter(req)); + + req.getHeaders().putSingle(HttpHeaders.USER_AGENT, "Signal-Android/100.0.0"); + assertThrows(WebApplicationException.class, () -> filter.filter(req)); + } + +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/FakeDynamicConfigurationManager.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/FakeDynamicConfigurationManager.java new file mode 100644 index 000000000..94e0ae7eb --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/FakeDynamicConfigurationManager.java @@ -0,0 +1,24 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.tests.util; + +import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; + +public class FakeDynamicConfigurationManager extends DynamicConfigurationManager { + + T staticConfiguration; + + public FakeDynamicConfigurationManager(T staticConfiguration) { + super(null, (Class) staticConfiguration.getClass()); + this.staticConfiguration = staticConfiguration; + } + + @Override + public T getConfiguration() { + return staticConfiguration; + } + +}