-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Filter to block old REST API for specified client versions
- Loading branch information
1 parent
e4b0f3c
commit 5d06228
Showing
5 changed files
with
239 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
88 changes: 88 additions & 0 deletions
88
service/src/main/java/org/whispersystems/textsecuregcm/filters/RestDeprecationFilter.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} | ||
} |
111 changes: 111 additions & 0 deletions
111
...ice/src/test/java/org/whispersystems/textsecuregcm/filters/RestDeprecationFilterTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
} | ||
|
||
} |
24 changes: 24 additions & 0 deletions
24
...est/java/org/whispersystems/textsecuregcm/tests/util/FakeDynamicConfigurationManager.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
|
||
} |