diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 6b6e89acb..675a02ed5 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -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 accountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator(accountAuthenticator).buildAuthFilter (); AuthFilter disabledPermittedAccountAuthFilter = new BasicCredentialAuthFilter.Builder().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, diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfiguration.java index c3d2032bb..cdd19b368 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfiguration.java @@ -17,6 +17,10 @@ public class DynamicConfiguration { @Valid private DynamicRateLimitsConfiguration limits = new DynamicRateLimitsConfiguration(); + @JsonProperty + @Valid + private DynamicRemoteDeprecationConfiguration remoteDeprecation = new DynamicRemoteDeprecationConfiguration(); + public Optional 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; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRemoteDeprecationConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRemoteDeprecationConfiguration.java new file mode 100644 index 000000000..794a55d89 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRemoteDeprecationConfiguration.java @@ -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 minimumVersions = Collections.emptyMap(); + + @JsonProperty + private Map versionsPendingDeprecation = Collections.emptyMap(); + + @JsonProperty + private Map> blockedVersions = Collections.emptyMap(); + + @JsonProperty + private Map> versionsPendingBlock = Collections.emptyMap(); + + @JsonProperty + private boolean unrecognizedUserAgentAllowed = true; + + @VisibleForTesting + public void setMinimumVersions(final Map minimumVersions) { + this.minimumVersions = minimumVersions; + } + + public Map getMinimumVersions() { + return minimumVersions; + } + + @VisibleForTesting + public void setVersionsPendingDeprecation(final Map versionsPendingDeprecation) { + this.versionsPendingDeprecation = versionsPendingDeprecation; + } + + public Map getVersionsPendingDeprecation() { + return versionsPendingDeprecation; + } + + @VisibleForTesting + public void setUnrecognizedUserAgentAllowed(final boolean allowUnrecognizedUserAgents) { + this.unrecognizedUserAgentAllowed = allowUnrecognizedUserAgents; + } + + public boolean isUnrecognizedUserAgentAllowed() { + return unrecognizedUserAgentAllowed; + } + + @VisibleForTesting + public void setBlockedVersions(final Map> blockedVersions) { + this.blockedVersions = blockedVersions; + } + + public Map> getBlockedVersions() { + return blockedVersions; + } + + @VisibleForTesting + public void setVersionsPendingBlock(final Map> versionsPendingBlock) { + this.versionsPendingBlock = versionsPendingBlock; + } + + public Map> getVersionsPendingBlock() { + return versionsPendingBlock; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilter.java b/service/src/main/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilter.java new file mode 100644 index 000000000..b8494b3f2 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilter.java @@ -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 minimumVersionsByPlatform = configuration.getMinimumVersions(); + final Map versionsPendingDeprecationByPlatform = configuration.getVersionsPendingDeprecation(); + final Map> blockedVersionsByPlatform = configuration.getBlockedVersions(); + final Map> 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(); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java index 26614f86e..ce2ed26a7 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java @@ -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()); + } + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilterTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilterTest.java new file mode 100644 index 000000000..e796b9a38 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilterTest.java @@ -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 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 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> blockedVersionsByPlatform = new EnumMap<>(ClientPlatform.class); + blockedVersionsByPlatform.put(ClientPlatform.DESKTOP, Set.of(new Semver("8.0.0-beta.2"))); + + final EnumMap> 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()); + } + } +}