diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ValidatingInterceptor.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ValidatingInterceptor.java index a44307107..a5be479ec 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ValidatingInterceptor.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ValidatingInterceptor.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.grpc.validators.Base64UrlFieldValidator; import org.whispersystems.textsecuregcm.grpc.validators.E164FieldValidator; import org.whispersystems.textsecuregcm.grpc.validators.EnumSpecifiedFieldValidator; import org.whispersystems.textsecuregcm.grpc.validators.ExactlySizeFieldValidator; @@ -37,7 +38,8 @@ public class ValidatingInterceptor implements ServerInterceptor { "org.signal.chat.require.e164", new E164FieldValidator(), "org.signal.chat.require.exactlySize", new ExactlySizeFieldValidator(), "org.signal.chat.require.range", new RangeFieldValidator(), - "org.signal.chat.require.size", new SizeFieldValidator() + "org.signal.chat.require.size", new SizeFieldValidator(), + "org.signal.chat.require.base64url", new Base64UrlFieldValidator() ); @Override diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/Base64UrlFieldValidator.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/Base64UrlFieldValidator.java new file mode 100644 index 000000000..4197a75c9 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/Base64UrlFieldValidator.java @@ -0,0 +1,39 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc.validators; + +import com.google.protobuf.Descriptors; +import org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException; +import org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException; +import org.whispersystems.textsecuregcm.util.Util; + +import java.util.Base64; +import java.util.Objects; +import java.util.Set; + +/// Validate that a string field is a valid base64 url string (padded or unpadded) +public class Base64UrlFieldValidator extends BaseFieldValidator { + + public Base64UrlFieldValidator() { + super("base64url", Set.of(Descriptors.FieldDescriptor.Type.STRING), MissingOptionalAction.SUCCEED, false); + } + + @Override + protected Boolean resolveExtensionValue(final Object extensionValue) throws FieldValidationException { + return requireFlagExtension(extensionValue); + } + + @Override + protected void validateStringValue( + final Boolean extensionValue, + final String fieldValue) throws FieldValidationException { + try { + Base64.getUrlDecoder().decode(fieldValue); + } catch (IllegalArgumentException e) { + throw new FieldValidationException("value is not valid base64 url"); + } + } +} diff --git a/service/src/main/proto/org/signal/chat/backups.proto b/service/src/main/proto/org/signal/chat/backups.proto index 01b09a6f6..a5d87820a 100644 --- a/service/src/main/proto/org/signal/chat/backups.proto +++ b/service/src/main/proto/org/signal/chat/backups.proto @@ -390,7 +390,7 @@ message CopyMediaItem { uint32 source_attachment_cdn = 1 [(require.range).min = 1, (require.range).max = 3]; // The attachment key of the object to copy into the backup - string source_key = 2 [(require.nonEmpty) = true]; + string source_key = 2 [(require.nonEmpty) = true, (require.base64url) = true]; // The length of the source attachment before the encryption applied by the // copy operation diff --git a/service/src/main/proto/org/signal/chat/require.proto b/service/src/main/proto/org/signal/chat/require.proto index 071c12a2a..bbfeaf1d1 100644 --- a/service/src/main/proto/org/signal/chat/require.proto +++ b/service/src/main/proto/org/signal/chat/require.proto @@ -147,6 +147,21 @@ extend google.protobuf.FieldOptions { *```` */ optional bool present = 70007; + + /* + * Requires a value of a string field to be a valid base64 URL string. The + * string may be padded or unpadded. If the field is `optional`, this check + * allows a value to be not set. + * + * ``` + * import "org/signal/chat/require.proto"; + * + * message Data { + * string myString = 1 [(require.base64url)]; + * } + * ``` + */ + optional bool base64url = 70008; } message SizeConstraint { diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ValidatingInterceptorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ValidatingInterceptorTest.java index a98fb47a2..fa60ba6c5 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ValidatingInterceptorTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ValidatingInterceptorTest.java @@ -103,6 +103,25 @@ public class ValidatingInterceptorTest { )); } + @ParameterizedTest + @ValueSource(strings = {"1====", "zzz?", "123/", "123+"}) + public void testBase64UrlValidationFailure(final String invalidBase64Url) { + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .setBase64Url(invalidBase64Url) + .build() + )); + } + + @ParameterizedTest + @ValueSource(strings = {"abc=", "ab==", "aa", "ab", "abc", "A_b-Cd"}) + public void testBase64UrlPermittedValues(final String validBase64Url) { + assertDoesNotThrow(() -> stub.validationsEndpoint( + builderWithValidDefaults() + .setBase64Url(validBase64Url) + .build())); + } + @ParameterizedTest @ValueSource(ints = {0, 1, 2, 3, 4, 6, 1000}) public void testExactlySizeValidationFailure(final int size) throws Exception { @@ -450,7 +469,8 @@ public class ValidatingInterceptorTest { .setI32Range(15) .setNested(NestedMessage.getDefaultInstance()) .addRepeatedNested(NestedMessage.getDefaultInstance()) - .putMapNested("test", NestedMessage.getDefaultInstance()); + .putMapNested("test", NestedMessage.getDefaultInstance()) + .setBase64Url("123aBc_-"); } private static void assertStatusException(final Status expected, final Executable serviceCall) { diff --git a/service/src/test/proto/validation_test.proto b/service/src/test/proto/validation_test.proto index 66f519cb7..7c3cd0345 100644 --- a/service/src/test/proto/validation_test.proto +++ b/service/src/test/proto/validation_test.proto @@ -77,6 +77,8 @@ message ValidationsRequest { RequirePresentMessage oneOfMessage = 31 [(require.present) = true]; bytes oneOfNonEmptyBytes = 32 [(require.nonEmpty) = true]; } + + optional string base64url = 33 [(require.base64url) = true]; } message NestedMessage {