Filter to block old REST API for specified client versions
This commit is contained in:
parent
e4b0f3ced5
commit
5d062285c2
|
@ -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<WhisperServerConfiguration
|
|||
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));
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
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;
|
||||
|
@ -13,6 +14,7 @@ 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 {
|
||||
|
||||
|
@ -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));
|
||||
|
@ -130,4 +136,8 @@ public class DynamicConfiguration {
|
|||
return svrStatusCodesToIgnoreForAccountDeletion;
|
||||
}
|
||||
|
||||
public Map<ClientPlatform, Semver> minimumRestFreeVersion() {
|
||||
return minimumRestFreeVersion;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue