Add support for remote client deprecation
This commit is contained in:
parent
b4350ec77b
commit
2f105ed0a4
|
@ -71,6 +71,7 @@ import org.whispersystems.textsecuregcm.controllers.SecureStorageController;
|
|||
import org.whispersystems.textsecuregcm.controllers.StickerController;
|
||||
import org.whispersystems.textsecuregcm.controllers.VoiceVerificationController;
|
||||
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
|
||||
import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter;
|
||||
import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.liquibase.NameableMigrationsBundle;
|
||||
|
@ -427,6 +428,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
AuthFilter<BasicCredentials, Account> accountAuthFilter = new BasicCredentialAuthFilter.Builder<Account>().setAuthenticator(accountAuthenticator).buildAuthFilter ();
|
||||
AuthFilter<BasicCredentials, DisabledPermittedAccount> disabledPermittedAccountAuthFilter = new BasicCredentialAuthFilter.Builder<DisabledPermittedAccount>().setAuthenticator(disabledPermittedAccountAuthenticator).buildAuthFilter();
|
||||
|
||||
environment.servlets().addFilter("RemoteDeprecationFilter", new RemoteDeprecationFilter(dynamicConfigurationManager))
|
||||
.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*");
|
||||
|
||||
environment.jersey().register(new MetricsApplicationEventListener(TrafficSource.HTTP));
|
||||
|
||||
environment.jersey().register(new PolymorphicAuthDynamicFeature<>(ImmutableMap.of(Account.class, accountAuthFilter,
|
||||
|
|
|
@ -17,6 +17,10 @@ public class DynamicConfiguration {
|
|||
@Valid
|
||||
private DynamicRateLimitsConfiguration limits = new DynamicRateLimitsConfiguration();
|
||||
|
||||
@JsonProperty
|
||||
@Valid
|
||||
private DynamicRemoteDeprecationConfiguration remoteDeprecation = new DynamicRemoteDeprecationConfiguration();
|
||||
|
||||
public Optional<DynamicExperimentEnrollmentConfiguration> getExperimentEnrollmentConfiguration(final String experimentName) {
|
||||
return Optional.ofNullable(experiments.get(experimentName));
|
||||
}
|
||||
|
@ -24,4 +28,8 @@ public class DynamicConfiguration {
|
|||
public DynamicRateLimitsConfiguration getLimits() {
|
||||
return limits;
|
||||
}
|
||||
|
||||
public DynamicRemoteDeprecationConfiguration getRemoteDeprecationConfiguration() {
|
||||
return remoteDeprecation;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration.dynamic;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.vdurmont.semver4j.Semver;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public class DynamicRemoteDeprecationConfiguration {
|
||||
|
||||
@JsonProperty
|
||||
private Map<ClientPlatform, Semver> minimumVersions = Collections.emptyMap();
|
||||
|
||||
@JsonProperty
|
||||
private Map<ClientPlatform, Semver> versionsPendingDeprecation = Collections.emptyMap();
|
||||
|
||||
@JsonProperty
|
||||
private Map<ClientPlatform, Set<Semver>> blockedVersions = Collections.emptyMap();
|
||||
|
||||
@JsonProperty
|
||||
private Map<ClientPlatform, Set<Semver>> versionsPendingBlock = Collections.emptyMap();
|
||||
|
||||
@JsonProperty
|
||||
private boolean unrecognizedUserAgentAllowed = true;
|
||||
|
||||
@VisibleForTesting
|
||||
public void setMinimumVersions(final Map<ClientPlatform, Semver> minimumVersions) {
|
||||
this.minimumVersions = minimumVersions;
|
||||
}
|
||||
|
||||
public Map<ClientPlatform, Semver> getMinimumVersions() {
|
||||
return minimumVersions;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setVersionsPendingDeprecation(final Map<ClientPlatform, Semver> versionsPendingDeprecation) {
|
||||
this.versionsPendingDeprecation = versionsPendingDeprecation;
|
||||
}
|
||||
|
||||
public Map<ClientPlatform, Semver> getVersionsPendingDeprecation() {
|
||||
return versionsPendingDeprecation;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setUnrecognizedUserAgentAllowed(final boolean allowUnrecognizedUserAgents) {
|
||||
this.unrecognizedUserAgentAllowed = allowUnrecognizedUserAgents;
|
||||
}
|
||||
|
||||
public boolean isUnrecognizedUserAgentAllowed() {
|
||||
return unrecognizedUserAgentAllowed;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setBlockedVersions(final Map<ClientPlatform, Set<Semver>> blockedVersions) {
|
||||
this.blockedVersions = blockedVersions;
|
||||
}
|
||||
|
||||
public Map<ClientPlatform, Set<Semver>> getBlockedVersions() {
|
||||
return blockedVersions;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public void setVersionsPendingBlock(final Map<ClientPlatform, Set<Semver>> versionsPendingBlock) {
|
||||
this.versionsPendingBlock = versionsPendingBlock;
|
||||
}
|
||||
|
||||
public Map<ClientPlatform, Set<Semver>> getVersionsPendingBlock() {
|
||||
return versionsPendingBlock;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* Copyright 2020 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.filters;
|
||||
|
||||
import static com.codahale.metrics.MetricRegistry.name;
|
||||
|
||||
import com.vdurmont.semver4j.Semver;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import javax.servlet.Filter;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.ServletRequest;
|
||||
import javax.servlet.ServletResponse;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRemoteDeprecationConfiguration;
|
||||
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;
|
||||
|
||||
/**
|
||||
* The remote deprecation filter rejects traffic from clients older than a configured minimum
|
||||
* version. It may optionally also reject traffic from clients with unrecognized User-Agent strings.
|
||||
* If a client platform does not have a configured minimum version, all traffic from that client
|
||||
* platform is allowed.
|
||||
*/
|
||||
public class RemoteDeprecationFilter implements Filter {
|
||||
|
||||
private final DynamicConfigurationManager dynamicConfigurationManager;
|
||||
|
||||
private static final String DEPRECATED_CLIENT_COUNTER_NAME = name(RemoteDeprecationFilter.class, "deprecated");
|
||||
private static final String PENDING_DEPRECATION_COUNTER_NAME = name(RemoteDeprecationFilter.class, "pendingDeprecation");
|
||||
private static final String PLATFORM_TAG = "platform";
|
||||
private static final String REASON_TAG_NAME = "reason";
|
||||
private static final String EXPIRED_CLIENT_REASON = "expired";
|
||||
private static final String BLOCKED_CLIENT_REASON = "blocked";
|
||||
private static final String UNRECOGNIZED_UA_REASON = "unrecognized_user_agent";
|
||||
|
||||
public RemoteDeprecationFilter(final DynamicConfigurationManager dynamicConfigurationManager) {
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
|
||||
final DynamicRemoteDeprecationConfiguration configuration = dynamicConfigurationManager
|
||||
.getConfiguration().getRemoteDeprecationConfiguration();
|
||||
|
||||
final Map<ClientPlatform, Semver> minimumVersionsByPlatform = configuration.getMinimumVersions();
|
||||
final Map<ClientPlatform, Semver> versionsPendingDeprecationByPlatform = configuration.getVersionsPendingDeprecation();
|
||||
final Map<ClientPlatform, Set<Semver>> blockedVersionsByPlatform = configuration.getBlockedVersions();
|
||||
final Map<ClientPlatform, Set<Semver>> versionsPendingBlockByPlatform = configuration.getVersionsPendingBlock();
|
||||
final boolean allowUnrecognizedUserAgents = configuration.isUnrecognizedUserAgentAllowed();
|
||||
|
||||
boolean shouldBlock = false;
|
||||
|
||||
try {
|
||||
final String userAgentString = ((HttpServletRequest) request).getHeader("User-Agent");
|
||||
final UserAgent userAgent = UserAgentUtil.parseUserAgentString(userAgentString);
|
||||
|
||||
if (blockedVersionsByPlatform.containsKey(userAgent.getPlatform())) {
|
||||
if (blockedVersionsByPlatform.get(userAgent.getPlatform()).contains(userAgent.getVersion())) {
|
||||
recordDeprecation(userAgent, BLOCKED_CLIENT_REASON);
|
||||
shouldBlock = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (minimumVersionsByPlatform.containsKey(userAgent.getPlatform())) {
|
||||
if (userAgent.getVersion().isLowerThan(minimumVersionsByPlatform.get(userAgent.getPlatform()))) {
|
||||
recordDeprecation(userAgent, EXPIRED_CLIENT_REASON);
|
||||
shouldBlock = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (versionsPendingBlockByPlatform.containsKey(userAgent.getPlatform())) {
|
||||
if (versionsPendingBlockByPlatform.get(userAgent.getPlatform()).contains(userAgent.getVersion())) {
|
||||
recordPendingDeprecation(userAgent, BLOCKED_CLIENT_REASON);
|
||||
}
|
||||
}
|
||||
|
||||
if (versionsPendingDeprecationByPlatform.containsKey(userAgent.getPlatform())) {
|
||||
if (userAgent.getVersion().isLowerThan(versionsPendingDeprecationByPlatform.get(userAgent.getPlatform()))) {
|
||||
recordPendingDeprecation(userAgent, EXPIRED_CLIENT_REASON);
|
||||
}
|
||||
}
|
||||
} catch (final UnrecognizedUserAgentException e) {
|
||||
if (!allowUnrecognizedUserAgents) {
|
||||
recordDeprecation(null, UNRECOGNIZED_UA_REASON);
|
||||
shouldBlock = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldBlock) {
|
||||
((HttpServletResponse) response).sendError(499);
|
||||
} else {
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
|
||||
private void recordDeprecation(final UserAgent userAgent, final String reason) {
|
||||
Metrics.counter(DEPRECATED_CLIENT_COUNTER_NAME,
|
||||
PLATFORM_TAG, userAgent != null ? userAgent.getPlatform().name().toLowerCase() : "unrecognized",
|
||||
REASON_TAG_NAME, reason).increment();
|
||||
}
|
||||
|
||||
private void recordPendingDeprecation(final UserAgent userAgent, final String reason) {
|
||||
Metrics.counter(PENDING_DEPRECATION_COUNTER_NAME,
|
||||
PLATFORM_TAG, userAgent.getPlatform().name().toLowerCase(),
|
||||
REASON_TAG_NAME, reason).increment();
|
||||
}
|
||||
}
|
|
@ -6,10 +6,13 @@
|
|||
package org.whispersystems.textsecuregcm.configuration.dynamic;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.vdurmont.semver4j.Semver;
|
||||
import org.junit.Test;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
|
@ -59,4 +62,37 @@ public class DynamicConfigurationTest {
|
|||
config.getExperimentEnrollmentConfiguration("uuidsOnly").get().getEnrolledUuids());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testParseRemoteDeprecationConfig() throws JsonProcessingException {
|
||||
{
|
||||
final String emptyConfigYaml = "test: true";
|
||||
final DynamicConfiguration emptyConfig = DynamicConfigurationManager.OBJECT_MAPPER.readValue(emptyConfigYaml, DynamicConfiguration.class);
|
||||
|
||||
assertNotNull(emptyConfig.getRemoteDeprecationConfiguration());
|
||||
}
|
||||
|
||||
{
|
||||
final String experimentConfigYaml =
|
||||
"remoteDeprecation:\n" +
|
||||
" minimumVersions:\n" +
|
||||
" IOS: 1.2.3\n" +
|
||||
" ANDROID: 4.5.6\n" +
|
||||
|
||||
" versionsPendingDeprecation:\n" +
|
||||
" DESKTOP: 7.8.9\n" +
|
||||
|
||||
" blockedVersions:\n" +
|
||||
" DESKTOP:\n" +
|
||||
" - 1.4.0-beta.2";
|
||||
|
||||
final DynamicConfiguration config = DynamicConfigurationManager.OBJECT_MAPPER.readValue(experimentConfigYaml, DynamicConfiguration.class);
|
||||
final DynamicRemoteDeprecationConfiguration remoteDeprecationConfiguration = config.getRemoteDeprecationConfiguration();
|
||||
|
||||
assertEquals(Map.of(ClientPlatform.IOS, new Semver("1.2.3"), ClientPlatform.ANDROID, new Semver("4.5.6")), remoteDeprecationConfiguration.getMinimumVersions());
|
||||
assertEquals(Map.of(ClientPlatform.DESKTOP, new Semver("7.8.9")), remoteDeprecationConfiguration.getVersionsPendingDeprecation());
|
||||
assertEquals(Map.of(ClientPlatform.DESKTOP, Set.of(new Semver("1.4.0-beta.2"))), remoteDeprecationConfiguration.getBlockedVersions());
|
||||
assertTrue(remoteDeprecationConfiguration.getVersionsPendingBlock().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,117 @@
|
|||
/*
|
||||
* Copyright 2020 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.filters;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.vdurmont.semver4j.Semver;
|
||||
import java.io.IOException;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Set;
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import junitparams.JUnitParamsRunner;
|
||||
import junitparams.Parameters;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRemoteDeprecationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
|
||||
@RunWith(JUnitParamsRunner.class)
|
||||
public class RemoteDeprecationFilterTest {
|
||||
|
||||
@Test
|
||||
public void testEmptyMap() throws IOException, ServletException {
|
||||
// We're happy as long as there's no exception
|
||||
final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
|
||||
final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
|
||||
final DynamicRemoteDeprecationConfiguration emptyConfiguration = new DynamicRemoteDeprecationConfiguration();
|
||||
|
||||
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
|
||||
when(dynamicConfiguration.getRemoteDeprecationConfiguration()).thenReturn(emptyConfiguration);
|
||||
|
||||
final RemoteDeprecationFilter filter = new RemoteDeprecationFilter(dynamicConfigurationManager);
|
||||
|
||||
final HttpServletRequest servletRequest = mock(HttpServletRequest.class);
|
||||
final HttpServletResponse servletResponse = mock(HttpServletResponse.class);
|
||||
final FilterChain filterChain = mock(FilterChain.class);
|
||||
|
||||
when(servletRequest.getHeader("UserAgent")).thenReturn("Signal-Android/4.68.3");
|
||||
|
||||
filter.doFilter(servletRequest, servletResponse, filterChain);
|
||||
|
||||
verify(filterChain).doFilter(servletRequest, servletResponse);
|
||||
verify(servletResponse, never()).sendError(anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Parameters({"Unrecognized UA | false",
|
||||
"Signal-Android/4.68.3 | false",
|
||||
"Signal-iOS/3.9.0 | false",
|
||||
"Signal-Desktop/1.2.3 | false",
|
||||
"Signal-Android/0.68.3 | true",
|
||||
"Signal-iOS/0.9.0 | true",
|
||||
"Signal-Desktop/0.2.3 | true",
|
||||
"Signal-Desktop/8.0.0-beta.2 | true",
|
||||
"Signal-Desktop/8.0.0-beta.1 | false",
|
||||
"Signal-iOS/8.0.0-beta.2 | false"})
|
||||
public void testFilter(final String userAgent, final boolean expectDeprecation) throws IOException, ServletException {
|
||||
final EnumMap<ClientPlatform, Semver> minimumVersionsByPlatform = new EnumMap<>(ClientPlatform.class);
|
||||
minimumVersionsByPlatform.put(ClientPlatform.ANDROID, new Semver("1.0.0"));
|
||||
minimumVersionsByPlatform.put(ClientPlatform.IOS, new Semver("1.0.0"));
|
||||
minimumVersionsByPlatform.put(ClientPlatform.DESKTOP, new Semver("1.0.0"));
|
||||
|
||||
final EnumMap<ClientPlatform, Semver> versionsPendingDeprecationByPlatform = new EnumMap<>(ClientPlatform.class);
|
||||
minimumVersionsByPlatform.put(ClientPlatform.ANDROID, new Semver("1.1.0"));
|
||||
minimumVersionsByPlatform.put(ClientPlatform.IOS, new Semver("1.1.0"));
|
||||
minimumVersionsByPlatform.put(ClientPlatform.DESKTOP, new Semver("1.1.0"));
|
||||
|
||||
final EnumMap<ClientPlatform, Set<Semver>> blockedVersionsByPlatform = new EnumMap<>(ClientPlatform.class);
|
||||
blockedVersionsByPlatform.put(ClientPlatform.DESKTOP, Set.of(new Semver("8.0.0-beta.2")));
|
||||
|
||||
final EnumMap<ClientPlatform, Set<Semver>> versionsPendingBlockByPlatform = new EnumMap<>(ClientPlatform.class);
|
||||
versionsPendingBlockByPlatform.put(ClientPlatform.DESKTOP, Set.of(new Semver("8.0.0-beta.3")));
|
||||
|
||||
final DynamicRemoteDeprecationConfiguration remoteDeprecationConfiguration = new DynamicRemoteDeprecationConfiguration();
|
||||
remoteDeprecationConfiguration.setMinimumVersions(minimumVersionsByPlatform);
|
||||
remoteDeprecationConfiguration.setVersionsPendingDeprecation(versionsPendingDeprecationByPlatform);
|
||||
remoteDeprecationConfiguration.setBlockedVersions(blockedVersionsByPlatform);
|
||||
remoteDeprecationConfiguration.setVersionsPendingBlock(versionsPendingBlockByPlatform);
|
||||
remoteDeprecationConfiguration.setUnrecognizedUserAgentAllowed(true);
|
||||
|
||||
final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);
|
||||
final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class);
|
||||
|
||||
when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);
|
||||
when(dynamicConfiguration.getRemoteDeprecationConfiguration()).thenReturn(remoteDeprecationConfiguration);
|
||||
|
||||
final HttpServletRequest servletRequest = mock(HttpServletRequest.class);
|
||||
final HttpServletResponse servletResponse = mock(HttpServletResponse.class);
|
||||
final FilterChain filterChain = mock(FilterChain.class);
|
||||
|
||||
when(servletRequest.getHeader("User-Agent")).thenReturn(userAgent);
|
||||
|
||||
final RemoteDeprecationFilter filter = new RemoteDeprecationFilter(dynamicConfigurationManager);
|
||||
filter.doFilter(servletRequest, servletResponse, filterChain);
|
||||
|
||||
if (expectDeprecation) {
|
||||
verify(filterChain, never()).doFilter(any(), any());
|
||||
verify(servletResponse).sendError(499);
|
||||
} else {
|
||||
verify(filterChain).doFilter(servletRequest, servletResponse);
|
||||
verify(servletResponse, never()).sendError(anyInt());
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue