Skip to content

Commit

Permalink
Filter to block old REST API for specified client versions
Browse files Browse the repository at this point in the history
  • Loading branch information
jkt-signal authored Feb 5, 2025
1 parent e4b0f3c commit 5d06228
Show file tree
Hide file tree
Showing 5 changed files with 239 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@
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;
Expand Down Expand Up @@ -1001,7 +1002,12 @@ protected void configureServer(final ServerBuilder<?> serverBuilder) {
metricsHttpChannelListener.configure(environment);
final MessageMetrics messageMetrics = new MessageMetrics();

// BufferingInterceptor is needed on the base environment but not the WebSocketEnvironment,
// because we handle serialization of http responses on the websocket on our own and can
// compute content lengths without it
environment.jersey().register(new BufferingInterceptor());
environment.jersey().register(new RestDeprecationFilter(dynamicConfigurationManager, experimentEnrollmentManager));

environment.jersey().register(new VirtualExecutorServiceProvider("managed-async-virtual-thread-"));
environment.jersey().register(new RateLimitByIpFilter(rateLimiters));
environment.jersey().register(new RequestStatisticsFilter(TrafficSource.HTTP));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
package org.whispersystems.textsecuregcm.configuration.dynamic;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.vdurmont.semver4j.Semver;
import jakarta.validation.Valid;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.whispersystems.textsecuregcm.limits.RateLimiterConfig;
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;

public class DynamicConfiguration {

Expand Down Expand Up @@ -72,6 +74,10 @@ public class DynamicConfiguration {
@Valid
List<String> svrStatusCodesToIgnoreForAccountDeletion = Collections.emptyList();

@JsonProperty
@Valid
Map<ClientPlatform, Semver> minimumRestFreeVersion = Map.of();

public Optional<DynamicExperimentEnrollmentConfiguration> getExperimentEnrollmentConfiguration(
final String experimentName) {
return Optional.ofNullable(experiments.get(experimentName));
Expand Down Expand Up @@ -130,4 +136,8 @@ public List<String> getSvrStatusCodesToIgnoreForAccountDeletion() {
return svrStatusCodesToIgnoreForAccountDeletion;
}

public Map<ClientPlatform, Semver> minimumRestFreeVersion() {
return minimumRestFreeVersion;
}

}
Original file line number Diff line number Diff line change
@@ -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<DynamicConfiguration> dynamicConfigurationManager;
final ExperimentEnrollmentManager experimentEnrollmentManager;

public RestDeprecationFilter(
final DynamicConfigurationManager<DynamicConfiguration> 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<ClientPlatform, Semver> 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
}
}
}
Original file line number Diff line number Diff line change
@@ -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<DynamicConfiguration> 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<DynamicConfiguration> 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<DynamicConfiguration> 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));
}

}
Original file line number Diff line number Diff line change
@@ -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<T> extends DynamicConfigurationManager<T> {

T staticConfiguration;

public FakeDynamicConfigurationManager(T staticConfiguration) {
super(null, (Class<T>) staticConfiguration.getClass());
this.staticConfiguration = staticConfiguration;
}

@Override
public T getConfiguration() {
return staticConfiguration;
}

}

0 comments on commit 5d06228

Please sign in to comment.