Reject old-format Benin numbers, which are now undeliverable

This commit is contained in:
Chris Eager 2025-01-03 12:17:13 -06:00 committed by Chris Eager
parent f4a243861c
commit 3a4a55c245
7 changed files with 83 additions and 20 deletions

View File

@ -175,6 +175,7 @@ import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMa
import org.whispersystems.textsecuregcm.mappers.InvalidWebsocketAddressExceptionMapper; import org.whispersystems.textsecuregcm.mappers.InvalidWebsocketAddressExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.JsonMappingExceptionMapper; import org.whispersystems.textsecuregcm.mappers.JsonMappingExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper; import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.ObsoletePhoneNumberFormatExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.RegistrationServiceSenderExceptionMapper; import org.whispersystems.textsecuregcm.mappers.RegistrationServiceSenderExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper; import org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper;
@ -1212,6 +1213,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new ServerRejectedExceptionMapper(), new ServerRejectedExceptionMapper(),
new ImpossiblePhoneNumberExceptionMapper(), new ImpossiblePhoneNumberExceptionMapper(),
new NonNormalizedPhoneNumberExceptionMapper(), new NonNormalizedPhoneNumberExceptionMapper(),
new ObsoletePhoneNumberFormatExceptionMapper(),
new RegistrationServiceSenderExceptionMapper(), new RegistrationServiceSenderExceptionMapper(),
new SubscriptionExceptionMapper(), new SubscriptionExceptionMapper(),
new JsonMappingExceptionMapper() new JsonMappingExceptionMapper()

View File

@ -95,6 +95,7 @@ import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
import org.whispersystems.textsecuregcm.storage.VerificationSessionManager; import org.whispersystems.textsecuregcm.storage.VerificationSessionManager;
import org.whispersystems.textsecuregcm.util.ExceptionUtils; import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.ObsoletePhoneNumberFormatException;
import org.whispersystems.textsecuregcm.util.Pair; import org.whispersystems.textsecuregcm.util.Pair;
import org.whispersystems.textsecuregcm.util.Util; import org.whispersystems.textsecuregcm.util.Util;
@ -173,7 +174,7 @@ public class VerificationController {
description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed", description = "If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed",
schema = @Schema(implementation = Integer.class))) schema = @Schema(implementation = Integer.class)))
public VerificationSessionResponse createSession(@NotNull @Valid final CreateVerificationSessionRequest request) public VerificationSessionResponse createSession(@NotNull @Valid final CreateVerificationSessionRequest request)
throws RateLimitExceededException { throws RateLimitExceededException, ObsoletePhoneNumberFormatException {
final Pair<String, PushNotification.TokenType> pushTokenAndType = validateAndExtractPushToken( final Pair<String, PushNotification.TokenType> pushTokenAndType = validateAndExtractPushToken(
request.getUpdateVerificationSessionRequest()); request.getUpdateVerificationSessionRequest());

View File

@ -0,0 +1,18 @@
package org.whispersystems.textsecuregcm.mappers;
import io.micrometer.core.instrument.Metrics;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.util.ObsoletePhoneNumberFormatException;
public class ObsoletePhoneNumberFormatExceptionMapper implements ExceptionMapper<ObsoletePhoneNumberFormatException> {
private static final String COUNTER_NAME = MetricsUtil.name(ObsoletePhoneNumberFormatExceptionMapper.class, "errors");
@Override
public Response toResponse(final ObsoletePhoneNumberFormatException exception) {
Metrics.counter(COUNTER_NAME, "regionCode", exception.getRegionCode()).increment();
return Response.status(499).build();
}
}

View File

@ -0,0 +1,15 @@
package org.whispersystems.textsecuregcm.util;
public class ObsoletePhoneNumberFormatException extends Exception {
private final String regionCode;
public ObsoletePhoneNumberFormatException(final String regionCode) {
super("The provided format is obsolete in %s".formatted(regionCode));
this.regionCode = regionCode;
}
public String getRegionCode() {
return regionCode;
}
}

View File

@ -27,7 +27,6 @@ import java.util.concurrent.TimeUnit;
import java.util.function.Function; import java.util.function.Function;
import java.util.random.RandomGenerator; import java.util.random.RandomGenerator;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
public class Util { public class Util {
@ -125,7 +124,7 @@ public class Util {
try { try {
final PhoneNumber phoneNumber = PHONE_NUMBER_UTIL.parse(number, null); final PhoneNumber phoneNumber = PHONE_NUMBER_UTIL.parse(number, null);
// Benin is changing phone number formats from +229 XXXXXXXX to +229 01XXXXXXXX starting on November 30, 2024 // Benin changed phone number formats from +229 XXXXXXXX to +229 01XXXXXXXX on November 30, 2024
if ("BJ".equals(PHONE_NUMBER_UTIL.getRegionCodeForNumber(phoneNumber))) { if ("BJ".equals(PHONE_NUMBER_UTIL.getRegionCodeForNumber(phoneNumber))) {
final String nationalSignificantNumber = PHONE_NUMBER_UTIL.getNationalSignificantNumber(phoneNumber); final String nationalSignificantNumber = PHONE_NUMBER_UTIL.getNationalSignificantNumber(phoneNumber);
final String alternateE164; final String alternateE164;
@ -176,7 +175,7 @@ public class Util {
throw new IllegalArgumentException("Numbers from different countries cannot be equivalent alternate forms"); throw new IllegalArgumentException("Numbers from different countries cannot be equivalent alternate forms");
} }
if (regions.contains("BJ")) { if (regions.contains("BJ")) {
// Benin is changing phone number formats from +229 XXXXXXXX to +229 01XXXXXXXX starting on November 30, 2024 // Benin changed phone number formats from +229 XXXXXXXX to +229 01XXXXXXXX on November 30, 2024
// We prefer the longest form for long-term stability // We prefer the longest form for long-term stability
return e164s.stream().sorted(Comparator.comparingInt(String::length).reversed()).findFirst(); return e164s.stream().sorted(Comparator.comparingInt(String::length).reversed()).findFirst();
} }
@ -217,7 +216,7 @@ public class Util {
} }
/** /**
* Benin is changing phone number formats from +229 XXXXXXXX to +229 01XXXXXXXX starting on November 30, 2024. * Benin changed phone number formats from +229 XXXXXXXX to +229 01XXXXXXXX on November 30, 2024
* *
* @param phoneNumber the phone number to check. * @param phoneNumber the phone number to check.
* @return whether the provided phone number is an old-format Benin phone number * @return whether the provided phone number is an old-format Benin phone number
@ -235,11 +234,9 @@ public class Util {
* @return the canonical phone number if applicable, otherwise the original phone number. * @return the canonical phone number if applicable, otherwise the original phone number.
*/ */
public static Phonenumber.PhoneNumber canonicalizePhoneNumber(final Phonenumber.PhoneNumber phoneNumber) public static Phonenumber.PhoneNumber canonicalizePhoneNumber(final Phonenumber.PhoneNumber phoneNumber)
throws NumberParseException { throws NumberParseException, ObsoletePhoneNumberFormatException {
if (isOldFormatBeninPhoneNumber(phoneNumber)) { if (isOldFormatBeninPhoneNumber(phoneNumber)) {
// Benin changed phone number formats from +229 XXXXXXXX to +229 01XXXXXXXX starting on November 30, 2024. throw new ObsoletePhoneNumberFormatException("bj");
final String newFormatNumber = "+22901" + PHONE_NUMBER_UTIL.getNationalSignificantNumber(phoneNumber);
return PhoneNumberUtil.getInstance().parse(newFormatNumber, null);
} }
return phoneNumber; return phoneNumber;
} }

View File

@ -66,6 +66,7 @@ import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper; import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper; import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.ObsoletePhoneNumberFormatExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.mappers.RegistrationServiceSenderExceptionMapper; import org.whispersystems.textsecuregcm.mappers.RegistrationServiceSenderExceptionMapper;
import org.whispersystems.textsecuregcm.push.PushNotificationManager; import org.whispersystems.textsecuregcm.push.PushNotificationManager;
@ -117,6 +118,7 @@ class VerificationControllerTest {
.addProvider(new RateLimitExceededExceptionMapper()) .addProvider(new RateLimitExceededExceptionMapper())
.addProvider(new ImpossiblePhoneNumberExceptionMapper()) .addProvider(new ImpossiblePhoneNumberExceptionMapper())
.addProvider(new NonNormalizedPhoneNumberExceptionMapper()) .addProvider(new NonNormalizedPhoneNumberExceptionMapper())
.addProvider(new ObsoletePhoneNumberFormatExceptionMapper())
.addProvider(new RegistrationServiceSenderExceptionMapper()) .addProvider(new RegistrationServiceSenderExceptionMapper())
.setMapper(SystemMapper.jsonMapper()) .setMapper(SystemMapper.jsonMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory()) .setTestContainerFactory(new GrizzlyWebTestContainerFactory())
@ -220,7 +222,7 @@ class VerificationControllerTest {
when(registrationServiceClient.createRegistrationSession(any(), anyBoolean(), any())) when(registrationServiceClient.createRegistrationSession(any(), anyBoolean(), any()))
.thenReturn( .thenReturn(
CompletableFuture.completedFuture( CompletableFuture.completedFuture(
new RegistrationServiceSession(SESSION_ID, NUMBER, false, null, null, null, new RegistrationServiceSession(SESSION_ID, requestedNumber, false, null, null, null,
SESSION_EXPIRATION_SECONDS))); SESSION_EXPIRATION_SECONDS)));
when(verificationSessionManager.insert(any(), any())) when(verificationSessionManager.insert(any(), any()))
.thenReturn(CompletableFuture.completedFuture(null)); .thenReturn(CompletableFuture.completedFuture(null));
@ -245,14 +247,36 @@ class VerificationControllerTest {
// libphonenumber 8.13.50 and on generate new-format numbers for Benin // libphonenumber 8.13.50 and on generate new-format numbers for Benin
final String newFormatBeninE164 = PhoneNumberUtil.getInstance() final String newFormatBeninE164 = PhoneNumberUtil.getInstance()
.format(PhoneNumberUtil.getInstance().getExampleNumber("BJ"), PhoneNumberUtil.PhoneNumberFormat.E164); .format(PhoneNumberUtil.getInstance().getExampleNumber("BJ"), PhoneNumberUtil.PhoneNumberFormat.E164);
final String oldFormatBeninE164 = newFormatBeninE164.replaceFirst("01", "");
return Stream.of( return Stream.of(
Arguments.of(oldFormatBeninE164, newFormatBeninE164),
Arguments.of(newFormatBeninE164, newFormatBeninE164), Arguments.of(newFormatBeninE164, newFormatBeninE164),
Arguments.of(NUMBER, NUMBER) Arguments.of(NUMBER, NUMBER)
); );
} }
@Test
void createBeninSessionFailure() {
// libphonenumber 8.13.50 and on generate new-format numbers for Benin
final String newFormatBeninE164 = PhoneNumberUtil.getInstance()
.format(PhoneNumberUtil.getInstance().getExampleNumber("BJ"), PhoneNumberUtil.PhoneNumberFormat.E164);
final String oldFormatBeninE164 = newFormatBeninE164.replaceFirst("01", "");
when(registrationServiceClient.createRegistrationSession(any(), anyBoolean(), any()))
.thenReturn(
CompletableFuture.completedFuture(
new RegistrationServiceSession(SESSION_ID, NUMBER, false, null, null, null,
SESSION_EXPIRATION_SECONDS)));
when(verificationSessionManager.insert(any(), any()))
.thenReturn(CompletableFuture.completedFuture(null));
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/verification/session")
.request()
.header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1");
try (Response response = request.post(Entity.json(createSessionJson(oldFormatBeninE164, "token", "fcm")))) {
assertEquals(499, response.getStatus());
}
}
@ParameterizedTest @ParameterizedTest
@MethodSource @MethodSource
void createSessionSuccess(final String pushToken, final String pushTokenType, void createSessionSuccess(final String pushToken, final String pushTokenType,

View File

@ -7,15 +7,17 @@ package org.whispersystems.textsecuregcm.util;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.i18n.phonenumbers.NumberParseException; import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil; import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.junit.jupiter.api.Test;
import java.util.stream.Stream; import java.util.stream.Stream;
import com.google.i18n.phonenumbers.Phonenumber; import javax.annotation.Nullable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.CsvSource;
@ -93,9 +95,13 @@ class UtilTest {
@ParameterizedTest @ParameterizedTest
@MethodSource @MethodSource
void normalizeBeninPhoneNumber(final Phonenumber.PhoneNumber beninNumber, final Phonenumber.PhoneNumber expectedBeninNumber) void normalizeBeninPhoneNumber(final Phonenumber.PhoneNumber beninNumber, final Phonenumber.PhoneNumber expectedBeninNumber, @Nullable Class<? extends Throwable> exception)
throws NumberParseException { throws Exception {
assertTrue(expectedBeninNumber.exactlySameAs(Util.canonicalizePhoneNumber(beninNumber))); if (exception == null) {
assertTrue(expectedBeninNumber.exactlySameAs(Util.canonicalizePhoneNumber(beninNumber)));
} else {
assertThrows(exception, () -> Util.canonicalizePhoneNumber(beninNumber));
}
} }
private static Stream<Arguments> normalizeBeninPhoneNumber() throws NumberParseException { private static Stream<Arguments> normalizeBeninPhoneNumber() throws NumberParseException {
@ -103,9 +109,9 @@ class UtilTest {
final Phonenumber.PhoneNumber newFormatBeninPhoneNumber = PhoneNumberUtil.getInstance().parse(NEW_FORMAT_BENIN_E164_STRING, null); final Phonenumber.PhoneNumber newFormatBeninPhoneNumber = PhoneNumberUtil.getInstance().parse(NEW_FORMAT_BENIN_E164_STRING, null);
final Phonenumber.PhoneNumber usPhoneNumber = PhoneNumberUtil.getInstance().getExampleNumber("US"); final Phonenumber.PhoneNumber usPhoneNumber = PhoneNumberUtil.getInstance().getExampleNumber("US");
return Stream.of( return Stream.of(
Arguments.of(newFormatBeninPhoneNumber, newFormatBeninPhoneNumber), Arguments.of(newFormatBeninPhoneNumber, newFormatBeninPhoneNumber, null),
Arguments.of(oldFormatBeninPhoneNumber, newFormatBeninPhoneNumber), Arguments.of(oldFormatBeninPhoneNumber, null, ObsoletePhoneNumberFormatException.class),
Arguments.of(usPhoneNumber, usPhoneNumber) Arguments.of(usPhoneNumber, usPhoneNumber, null)
); );
} }
} }