Add support for remote client deprecation

This commit is contained in:
Jon Chambers 2021-02-09 12:24:44 -05:00 committed by GitHub
parent b4350ec77b
commit 2f105ed0a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 362 additions and 0 deletions

View File

@ -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,

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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());
}
}
}

View File

@ -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());
}
}
}