gRPC validations
This commit is contained in:
parent
115431a486
commit
db63ff6b88
|
@ -0,0 +1,96 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.grpc;
|
||||||
|
|
||||||
|
import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.internalError;
|
||||||
|
|
||||||
|
import com.google.protobuf.Descriptors;
|
||||||
|
import com.google.protobuf.GeneratedMessageV3;
|
||||||
|
import io.grpc.ForwardingServerCallListener;
|
||||||
|
import io.grpc.Metadata;
|
||||||
|
import io.grpc.ServerCall;
|
||||||
|
import io.grpc.ServerCallHandler;
|
||||||
|
import io.grpc.ServerInterceptor;
|
||||||
|
import io.grpc.StatusException;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.whispersystems.textsecuregcm.grpc.validators.E164FieldValidator;
|
||||||
|
import org.whispersystems.textsecuregcm.grpc.validators.EnumSpecifiedFieldValidator;
|
||||||
|
import org.whispersystems.textsecuregcm.grpc.validators.ExactlySizeFieldValidator;
|
||||||
|
import org.whispersystems.textsecuregcm.grpc.validators.FieldValidator;
|
||||||
|
import org.whispersystems.textsecuregcm.grpc.validators.NonEmptyFieldValidator;
|
||||||
|
import org.whispersystems.textsecuregcm.grpc.validators.RangeFieldValidator;
|
||||||
|
import org.whispersystems.textsecuregcm.grpc.validators.SizeFieldValidator;
|
||||||
|
|
||||||
|
public class ValidatingInterceptor implements ServerInterceptor {
|
||||||
|
|
||||||
|
private final Map<String, FieldValidator> fieldValidators = Map.of(
|
||||||
|
"org.signal.chat.require.nonEmpty", new NonEmptyFieldValidator(),
|
||||||
|
"org.signal.chat.require.specified", new EnumSpecifiedFieldValidator(),
|
||||||
|
"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()
|
||||||
|
);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
|
||||||
|
final ServerCall<ReqT, RespT> call,
|
||||||
|
final Metadata headers,
|
||||||
|
final ServerCallHandler<ReqT, RespT> next) {
|
||||||
|
return new ForwardingServerCallListener.SimpleForwardingServerCallListener<>(next.startCall(call, headers)) {
|
||||||
|
|
||||||
|
// The way `UnaryServerCallHandler` (which is what we're wrapping here) is implemented
|
||||||
|
// is when `onMessage()` is called, the processing of the message doesn't immediately start
|
||||||
|
// and instead is delayed until `onHalfClose()` (which is the point when client says
|
||||||
|
// that no more messages will be sent). Then, in `onHalfClose()` it either tries to process
|
||||||
|
// the message if it's there, or reports an error if the message is not there.
|
||||||
|
// This means that the logic is not designed for the case of the call being closed by the interceptor.
|
||||||
|
// The only workaround is to not delegate calls to it in the case when we're closing the call
|
||||||
|
// because of the validation error.
|
||||||
|
private boolean forwardCalls = true;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessage(final ReqT message) {
|
||||||
|
try {
|
||||||
|
validateMessage(message);
|
||||||
|
super.onMessage(message);
|
||||||
|
} catch (final StatusException e) {
|
||||||
|
call.close(e.getStatus(), new Metadata());
|
||||||
|
forwardCalls = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onHalfClose() {
|
||||||
|
if (forwardCalls) {
|
||||||
|
super.onHalfClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateMessage(final Object message) throws StatusException {
|
||||||
|
if (message instanceof GeneratedMessageV3 msg) {
|
||||||
|
try {
|
||||||
|
for (final Descriptors.FieldDescriptor fd: msg.getDescriptorForType().getFields()) {
|
||||||
|
for (final Map.Entry<Descriptors.FieldDescriptor, Object> entry: fd.getOptions().getAllFields().entrySet()) {
|
||||||
|
final Descriptors.FieldDescriptor extensionFieldDescriptor = entry.getKey();
|
||||||
|
final String extensionName = extensionFieldDescriptor.getFullName();
|
||||||
|
final FieldValidator validator = fieldValidators.get(extensionName);
|
||||||
|
// not all extensions are validators, so `validator` value here could legitimately be `null`
|
||||||
|
if (validator != null) {
|
||||||
|
validator.validate(entry.getValue(), fd, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (final StatusException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (final Exception e) {
|
||||||
|
throw internalError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,149 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.grpc.validators;
|
||||||
|
|
||||||
|
import static java.util.Objects.requireNonNull;
|
||||||
|
import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.internalError;
|
||||||
|
import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.invalidArgument;
|
||||||
|
|
||||||
|
import com.google.protobuf.ByteString;
|
||||||
|
import com.google.protobuf.Descriptors;
|
||||||
|
import com.google.protobuf.GeneratedMessageV3;
|
||||||
|
import io.grpc.Status;
|
||||||
|
import io.grpc.StatusException;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public abstract class BaseFieldValidator<T> implements FieldValidator {
|
||||||
|
|
||||||
|
private final String extensionName;
|
||||||
|
|
||||||
|
private final Set<Descriptors.FieldDescriptor.Type> supportedTypes;
|
||||||
|
|
||||||
|
private final MissingOptionalAction missingOptionalAction;
|
||||||
|
|
||||||
|
private final boolean applicableToRepeated;
|
||||||
|
|
||||||
|
protected enum MissingOptionalAction {
|
||||||
|
FAIL,
|
||||||
|
SUCCEED,
|
||||||
|
VALIDATE_DEFAULT_VALUE
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
protected BaseFieldValidator(
|
||||||
|
final String extensionName,
|
||||||
|
final Set<Descriptors.FieldDescriptor.Type> supportedTypes,
|
||||||
|
final MissingOptionalAction missingOptionalAction,
|
||||||
|
final boolean applicableToRepeated) {
|
||||||
|
this.extensionName = requireNonNull(extensionName);
|
||||||
|
this.supportedTypes = requireNonNull(supportedTypes);
|
||||||
|
this.missingOptionalAction = missingOptionalAction;
|
||||||
|
this.applicableToRepeated = applicableToRepeated;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void validate(
|
||||||
|
final Object extensionValue,
|
||||||
|
final Descriptors.FieldDescriptor fd,
|
||||||
|
final GeneratedMessageV3 msg) throws StatusException {
|
||||||
|
try {
|
||||||
|
final T extensionValueTyped = resolveExtensionValue(extensionValue);
|
||||||
|
|
||||||
|
// for the fields with an `optional` modifier, checking if the field was set
|
||||||
|
// and if not, checking if extension allows missing optional field
|
||||||
|
if (fd.hasPresence() && !msg.hasField(fd)) {
|
||||||
|
switch (missingOptionalAction) {
|
||||||
|
case FAIL -> {
|
||||||
|
throw invalidArgument("extension requires a value to be set");
|
||||||
|
}
|
||||||
|
case SUCCEED -> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case VALIDATE_DEFAULT_VALUE -> {
|
||||||
|
// just continuing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// for the `repeated` fields, checking if it's supported by the extension
|
||||||
|
if (fd.isRepeated()) {
|
||||||
|
if (applicableToRepeated) {
|
||||||
|
validateRepeatedField(extensionValueTyped, fd, msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw internalError("can't apply extension to a `repeated` field");
|
||||||
|
}
|
||||||
|
|
||||||
|
// checking field type against the set of supported types
|
||||||
|
final Descriptors.FieldDescriptor.Type type = fd.getType();
|
||||||
|
if (!supportedTypes.contains(type)) {
|
||||||
|
throw internalError("can't apply extension to a field of type [%s]".formatted(type));
|
||||||
|
}
|
||||||
|
switch (type) {
|
||||||
|
case INT64, UINT64, INT32, FIXED64, FIXED32, UINT32, SFIXED32, SFIXED64, SINT32, SINT64 ->
|
||||||
|
validateIntegerNumber(extensionValueTyped, ((Number) msg.getField(fd)).longValue(), type);
|
||||||
|
case STRING ->
|
||||||
|
validateStringValue(extensionValueTyped, (String) msg.getField(fd));
|
||||||
|
case BYTES ->
|
||||||
|
validateBytesValue(extensionValueTyped, (ByteString) msg.getField(fd));
|
||||||
|
case ENUM ->
|
||||||
|
validateEnumValue(extensionValueTyped, (Descriptors.EnumValueDescriptor) msg.getField(fd));
|
||||||
|
case FLOAT, DOUBLE, BOOL, MESSAGE, GROUP -> {
|
||||||
|
// at this moment, there are no validations specific to these types of fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (StatusException e) {
|
||||||
|
throw new StatusException(e.getStatus().withDescription(
|
||||||
|
"field [%s], extension [%s]: %s".formatted(fd.getName(), extensionName, e.getStatus().getDescription())
|
||||||
|
), e.getTrailers());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw Status.INTERNAL
|
||||||
|
.withDescription("field [%s], extension [%s]: %s".formatted(fd.getName(), extensionName, e.getMessage()))
|
||||||
|
.withCause(e)
|
||||||
|
.asException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract T resolveExtensionValue(final Object extensionValue) throws StatusException;
|
||||||
|
|
||||||
|
protected void validateRepeatedField(
|
||||||
|
final T extensionValue,
|
||||||
|
final Descriptors.FieldDescriptor fd,
|
||||||
|
final GeneratedMessageV3 msg) throws StatusException {
|
||||||
|
throw internalError("`validateRepeatedField` method needs to be implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void validateIntegerNumber(
|
||||||
|
final T extensionValue,
|
||||||
|
final long fieldValue, final Descriptors.FieldDescriptor.Type type) throws StatusException {
|
||||||
|
throw internalError("`validateIntegerNumber` method needs to be implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void validateStringValue(
|
||||||
|
final T extensionValue,
|
||||||
|
final String fieldValue) throws StatusException {
|
||||||
|
throw internalError("`validateStringValue` method needs to be implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void validateBytesValue(
|
||||||
|
final T extensionValue,
|
||||||
|
final ByteString fieldValue) throws StatusException {
|
||||||
|
throw internalError("`validateBytesValue` method needs to be implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void validateEnumValue(
|
||||||
|
final T extensionValue,
|
||||||
|
final Descriptors.EnumValueDescriptor enumValueDescriptor) throws StatusException {
|
||||||
|
throw internalError("`validateEnumValue` method needs to be implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static boolean requireFlagExtension(final Object extensionValue) throws StatusException {
|
||||||
|
if (extensionValue instanceof Boolean flagIsOn && flagIsOn) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw internalError("only value `true` is allowed");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.grpc.validators;
|
||||||
|
|
||||||
|
import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.invalidArgument;
|
||||||
|
|
||||||
|
import com.google.protobuf.Descriptors;
|
||||||
|
import io.grpc.StatusException;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException;
|
||||||
|
import org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException;
|
||||||
|
import org.whispersystems.textsecuregcm.util.Util;
|
||||||
|
|
||||||
|
public class E164FieldValidator extends BaseFieldValidator<Boolean> {
|
||||||
|
|
||||||
|
public E164FieldValidator() {
|
||||||
|
super("e164", Set.of(Descriptors.FieldDescriptor.Type.STRING), MissingOptionalAction.SUCCEED, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Boolean resolveExtensionValue(final Object extensionValue) throws StatusException {
|
||||||
|
return requireFlagExtension(extensionValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void validateStringValue(
|
||||||
|
final Boolean extensionValue,
|
||||||
|
final String fieldValue) throws StatusException {
|
||||||
|
try {
|
||||||
|
Util.requireNormalizedNumber(fieldValue);
|
||||||
|
} catch (final ImpossiblePhoneNumberException | NonNormalizedPhoneNumberException e) {
|
||||||
|
throw invalidArgument("value is not in E164 format");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.grpc.validators;
|
||||||
|
|
||||||
|
import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.invalidArgument;
|
||||||
|
|
||||||
|
import com.google.protobuf.Descriptors;
|
||||||
|
import io.grpc.StatusException;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class EnumSpecifiedFieldValidator extends BaseFieldValidator<Boolean> {
|
||||||
|
|
||||||
|
public EnumSpecifiedFieldValidator() {
|
||||||
|
super("specified", Set.of(Descriptors.FieldDescriptor.Type.ENUM), MissingOptionalAction.FAIL, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Boolean resolveExtensionValue(final Object extensionValue) throws StatusException {
|
||||||
|
return requireFlagExtension(extensionValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void validateEnumValue(
|
||||||
|
final Boolean extensionValue,
|
||||||
|
final Descriptors.EnumValueDescriptor enumValueDescriptor) throws StatusException {
|
||||||
|
if (enumValueDescriptor.getIndex() <= 0) {
|
||||||
|
throw invalidArgument("enum field must be specified");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,63 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.grpc.validators;
|
||||||
|
|
||||||
|
import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.invalidArgument;
|
||||||
|
|
||||||
|
import com.google.protobuf.ByteString;
|
||||||
|
import com.google.protobuf.Descriptors;
|
||||||
|
import com.google.protobuf.GeneratedMessageV3;
|
||||||
|
import io.grpc.StatusException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class ExactlySizeFieldValidator extends BaseFieldValidator<Set<Integer>> {
|
||||||
|
|
||||||
|
public ExactlySizeFieldValidator() {
|
||||||
|
super("exactlySize", Set.of(
|
||||||
|
Descriptors.FieldDescriptor.Type.STRING,
|
||||||
|
Descriptors.FieldDescriptor.Type.BYTES
|
||||||
|
), MissingOptionalAction.VALIDATE_DEFAULT_VALUE, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Set<Integer> resolveExtensionValue(final Object extensionValue) throws StatusException {
|
||||||
|
//noinspection unchecked
|
||||||
|
return Set.copyOf((List<Integer>) extensionValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void validateBytesValue(
|
||||||
|
final Set<Integer> permittedSizes,
|
||||||
|
final ByteString fieldValue) throws StatusException {
|
||||||
|
if (permittedSizes.contains(fieldValue.size())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw invalidArgument("byte arrray length is [%d] but expected to be one of %s".formatted(fieldValue.size(), permittedSizes));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void validateStringValue(
|
||||||
|
final Set<Integer> permittedSizes,
|
||||||
|
final String fieldValue) throws StatusException {
|
||||||
|
if (permittedSizes.contains(fieldValue.length())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw invalidArgument("string length is [%d] but expected to be one of %s".formatted(fieldValue.length(), permittedSizes));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void validateRepeatedField(
|
||||||
|
final Set<Integer> permittedSizes,
|
||||||
|
final Descriptors.FieldDescriptor fd,
|
||||||
|
final GeneratedMessageV3 msg) throws StatusException {
|
||||||
|
final int size = msg.getRepeatedFieldCount(fd);
|
||||||
|
if (permittedSizes.contains(size)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw invalidArgument("list size is [%d] but expected to be one of %s".formatted(size, permittedSizes));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.grpc.validators;
|
||||||
|
|
||||||
|
import com.google.protobuf.Descriptors;
|
||||||
|
import com.google.protobuf.GeneratedMessageV3;
|
||||||
|
import io.grpc.StatusException;
|
||||||
|
|
||||||
|
public interface FieldValidator {
|
||||||
|
|
||||||
|
void validate(Object extensionValue, Descriptors.FieldDescriptor fd, GeneratedMessageV3 msg)
|
||||||
|
throws StatusException;
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.grpc.validators;
|
||||||
|
|
||||||
|
import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.invalidArgument;
|
||||||
|
|
||||||
|
import com.google.protobuf.ByteString;
|
||||||
|
import com.google.protobuf.Descriptors;
|
||||||
|
import com.google.protobuf.GeneratedMessageV3;
|
||||||
|
import io.grpc.StatusException;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
|
public class NonEmptyFieldValidator extends BaseFieldValidator<Boolean> {
|
||||||
|
|
||||||
|
public NonEmptyFieldValidator() {
|
||||||
|
super("nonEmpty", Set.of(
|
||||||
|
Descriptors.FieldDescriptor.Type.STRING,
|
||||||
|
Descriptors.FieldDescriptor.Type.BYTES
|
||||||
|
), MissingOptionalAction.FAIL, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Boolean resolveExtensionValue(final Object extensionValue) throws StatusException {
|
||||||
|
return requireFlagExtension(extensionValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void validateBytesValue(
|
||||||
|
final Boolean extensionValue,
|
||||||
|
final ByteString fieldValue) throws StatusException {
|
||||||
|
if (!fieldValue.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw invalidArgument("byte array expected to be non-empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void validateStringValue(
|
||||||
|
final Boolean extensionValue,
|
||||||
|
final String fieldValue) throws StatusException {
|
||||||
|
if (StringUtils.isNotEmpty(fieldValue)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw invalidArgument("string expected to be non-empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void validateRepeatedField(
|
||||||
|
final Boolean extensionValue,
|
||||||
|
final Descriptors.FieldDescriptor fd,
|
||||||
|
final GeneratedMessageV3 msg) throws StatusException {
|
||||||
|
if (msg.getRepeatedFieldCount(fd) > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw invalidArgument("repeated field is expected to be non-empty");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.grpc.validators;
|
||||||
|
|
||||||
|
public record Range(int min, int max) {
|
||||||
|
public Range {
|
||||||
|
if (min > max) {
|
||||||
|
throw new IllegalArgumentException("invalid range values: expected min <= max but have [%d, %d],".formatted(min, max));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.grpc.validators;
|
||||||
|
|
||||||
|
import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.invalidArgument;
|
||||||
|
|
||||||
|
import com.google.protobuf.Descriptors;
|
||||||
|
import io.grpc.StatusException;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.signal.chat.require.ValueRangeConstraint;
|
||||||
|
|
||||||
|
public class RangeFieldValidator extends BaseFieldValidator<Range> {
|
||||||
|
|
||||||
|
private static final Set<Descriptors.FieldDescriptor.Type> UNSIGNED_TYPES = Set.of(
|
||||||
|
Descriptors.FieldDescriptor.Type.FIXED32,
|
||||||
|
Descriptors.FieldDescriptor.Type.UINT32,
|
||||||
|
Descriptors.FieldDescriptor.Type.FIXED64,
|
||||||
|
Descriptors.FieldDescriptor.Type.UINT64
|
||||||
|
);
|
||||||
|
|
||||||
|
public RangeFieldValidator() {
|
||||||
|
super("range", Set.of(
|
||||||
|
Descriptors.FieldDescriptor.Type.INT64,
|
||||||
|
Descriptors.FieldDescriptor.Type.UINT64,
|
||||||
|
Descriptors.FieldDescriptor.Type.INT32,
|
||||||
|
Descriptors.FieldDescriptor.Type.FIXED64,
|
||||||
|
Descriptors.FieldDescriptor.Type.FIXED32,
|
||||||
|
Descriptors.FieldDescriptor.Type.UINT32,
|
||||||
|
Descriptors.FieldDescriptor.Type.SFIXED32,
|
||||||
|
Descriptors.FieldDescriptor.Type.SFIXED64,
|
||||||
|
Descriptors.FieldDescriptor.Type.SINT32,
|
||||||
|
Descriptors.FieldDescriptor.Type.SINT64
|
||||||
|
), MissingOptionalAction.SUCCEED, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Range resolveExtensionValue(final Object extensionValue) throws StatusException {
|
||||||
|
final ValueRangeConstraint rangeConstraint = (ValueRangeConstraint) extensionValue;
|
||||||
|
final int min = rangeConstraint.hasMin() ? rangeConstraint.getMin() : Integer.MIN_VALUE;
|
||||||
|
final int max = rangeConstraint.hasMax() ? rangeConstraint.getMax() : Integer.MAX_VALUE;
|
||||||
|
return new Range(min, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void validateIntegerNumber(
|
||||||
|
final Range range,
|
||||||
|
final long fieldValue,
|
||||||
|
final Descriptors.FieldDescriptor.Type type) throws StatusException {
|
||||||
|
if (fieldValue < 0 && UNSIGNED_TYPES.contains(type)) {
|
||||||
|
throw invalidArgument("field value is expected to be within the [%d, %d] range".formatted(
|
||||||
|
range.min(), range.max()));
|
||||||
|
}
|
||||||
|
if (fieldValue < range.min() || fieldValue > range.max()) {
|
||||||
|
throw invalidArgument("field value is [%d] but expected to be within the [%d, %d] range".formatted(
|
||||||
|
fieldValue, range.min(), range.max()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,58 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.grpc.validators;
|
||||||
|
|
||||||
|
import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.invalidArgument;
|
||||||
|
|
||||||
|
import com.google.protobuf.ByteString;
|
||||||
|
import com.google.protobuf.Descriptors;
|
||||||
|
import com.google.protobuf.GeneratedMessageV3;
|
||||||
|
import io.grpc.StatusException;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.signal.chat.require.SizeConstraint;
|
||||||
|
|
||||||
|
public class SizeFieldValidator extends BaseFieldValidator<Range> {
|
||||||
|
|
||||||
|
public SizeFieldValidator() {
|
||||||
|
super("size", Set.of(
|
||||||
|
Descriptors.FieldDescriptor.Type.STRING,
|
||||||
|
Descriptors.FieldDescriptor.Type.BYTES
|
||||||
|
), MissingOptionalAction.VALIDATE_DEFAULT_VALUE, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Range resolveExtensionValue(final Object extensionValue) throws StatusException {
|
||||||
|
final SizeConstraint sizeConstraint = (SizeConstraint) extensionValue;
|
||||||
|
final int min = sizeConstraint.hasMin() ? sizeConstraint.getMin() : 0;
|
||||||
|
final int max = sizeConstraint.hasMax() ? sizeConstraint.getMax() : Integer.MAX_VALUE;
|
||||||
|
return new Range(min, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void validateBytesValue(final Range range, final ByteString fieldValue) throws StatusException {
|
||||||
|
if (fieldValue.size() < range.min() || fieldValue.size() > range.max()) {
|
||||||
|
throw invalidArgument("field value is [%d] but expected to be within the [%d, %d] range".formatted(
|
||||||
|
fieldValue.size(), range.min(), range.max()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void validateStringValue(final Range range, final String fieldValue) throws StatusException {
|
||||||
|
if (fieldValue.length() < range.min() || fieldValue.length() > range.max()) {
|
||||||
|
throw invalidArgument("field value is [%d] but expected to be within the [%d, %d] range".formatted(
|
||||||
|
fieldValue.length(), range.min(), range.max()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void validateRepeatedField(final Range range, final Descriptors.FieldDescriptor fd, final GeneratedMessageV3 msg) throws StatusException {
|
||||||
|
final int size = msg.getRepeatedFieldCount(fd);
|
||||||
|
if (size < range.min() || size > range.max()) {
|
||||||
|
throw invalidArgument("field value is [%d] but expected to be within the [%d, %d] range".formatted(
|
||||||
|
size, range.min(), range.max()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.grpc.validators;
|
||||||
|
|
||||||
|
import com.google.protobuf.DescriptorProtos;
|
||||||
|
import com.google.protobuf.Descriptors;
|
||||||
|
import io.grpc.ServerServiceDefinition;
|
||||||
|
import io.grpc.Status;
|
||||||
|
import io.grpc.StatusException;
|
||||||
|
import io.grpc.protobuf.ProtoServiceDescriptorSupplier;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.signal.chat.require.Auth;
|
||||||
|
|
||||||
|
public final class ValidatorUtils {
|
||||||
|
|
||||||
|
public static final String REQUIRE_AUTH_EXTENSION_NAME = "org.signal.chat.require.auth";
|
||||||
|
|
||||||
|
private ValidatorUtils() {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StatusException invalidArgument(final String description) {
|
||||||
|
return Status.INVALID_ARGUMENT.withDescription(description).asException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StatusException internalError(final String description) {
|
||||||
|
return Status.INTERNAL.withDescription(description).asException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StatusException internalError(final Exception cause) {
|
||||||
|
return Status.INTERNAL.withCause(cause).asException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Optional<Auth> serviceAuthExtensionValue(final ServerServiceDefinition serviceDefinition) {
|
||||||
|
return serviceExtensionValueByName(serviceDefinition, REQUIRE_AUTH_EXTENSION_NAME)
|
||||||
|
.map(val -> Auth.valueOf((Descriptors.EnumValueDescriptor) val));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<Object> serviceExtensionValueByName(
|
||||||
|
final ServerServiceDefinition serviceDefinition,
|
||||||
|
final String fullExtensionName) {
|
||||||
|
final Object schemaDescriptor = serviceDefinition.getServiceDescriptor().getSchemaDescriptor();
|
||||||
|
if (schemaDescriptor instanceof ProtoServiceDescriptorSupplier protoServiceDescriptorSupplier) {
|
||||||
|
final DescriptorProtos.ServiceOptions options = protoServiceDescriptorSupplier.getServiceDescriptor().getOptions();
|
||||||
|
return options.getAllFields().entrySet()
|
||||||
|
.stream()
|
||||||
|
.filter(e -> e.getKey().getFullName().equals(fullExtensionName))
|
||||||
|
.map(Map.Entry::getValue)
|
||||||
|
.findFirst();
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,167 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
option java_multiple_files = true;
|
||||||
|
|
||||||
|
package org.signal.chat.require;
|
||||||
|
|
||||||
|
import "google/protobuf/descriptor.proto";
|
||||||
|
|
||||||
|
extend google.protobuf.FieldOptions {
|
||||||
|
/*
|
||||||
|
Requires a field to have content of non-zero size/length.
|
||||||
|
Applies to both `optional` and regular fields, i.e. if the field is not set or has a default value,
|
||||||
|
it's considered to be empty.
|
||||||
|
|
||||||
|
```
|
||||||
|
import "org/signal/chat/require.proto";
|
||||||
|
|
||||||
|
message Data {
|
||||||
|
|
||||||
|
string nonEmptyString = 1 [(require.nonEmpty) = true];
|
||||||
|
|
||||||
|
bytes nonEmptyBytes = 2 [(require.nonEmpty) = true];
|
||||||
|
|
||||||
|
optional string nonEmptyStringOptional = 3 [(require.nonEmpty) = true];
|
||||||
|
|
||||||
|
optional bytes nonEmptyBytesOptional = 4 [(require.nonEmpty) = true];
|
||||||
|
|
||||||
|
repeated string nonEmptyList = 5 [(require.nonEmpty) = true];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Applicable to fields of type `string`, `byte`, and `repeated` fields.
|
||||||
|
*/
|
||||||
|
optional bool nonEmpty = 70001;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Requires a enum field to have value with an index greater than zero.
|
||||||
|
Applies to both `optional` and regular fields, i.e. if the field is not set or has a default value,
|
||||||
|
its index will be <= 0.
|
||||||
|
|
||||||
|
```
|
||||||
|
import "org/signal/chat/require.proto";
|
||||||
|
|
||||||
|
message Data {
|
||||||
|
Color color = 1 [(require.specified) = true];
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Color {
|
||||||
|
COLOR_UNSPECIFIED = 0;
|
||||||
|
COLOR_RED = 1;
|
||||||
|
COLOR_GREEN = 2;
|
||||||
|
COLOR_BLUE = 3;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
*/
|
||||||
|
optional bool specified = 70002;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Requires a size/length of a field to be withing certain bounderies.
|
||||||
|
Applies to both `optional` and regular fields, i.e. if the field is not set or has a default value,
|
||||||
|
its size considered to be zero.
|
||||||
|
|
||||||
|
```
|
||||||
|
import "org/signal/chat/require.proto";
|
||||||
|
|
||||||
|
message Data {
|
||||||
|
|
||||||
|
string name = 1 [(require.size) = {min: 3, max: 8}];
|
||||||
|
|
||||||
|
optional string address = 2 [(require.size) = {min: 3, max: 8}];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Applicable to fields of type `string`, `byte`, and `repeated` fields.
|
||||||
|
*/
|
||||||
|
optional SizeConstraint size = 70003;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Requires a size/length of a field to be one of the specified values.
|
||||||
|
Applies to both `optional` and regular fields, i.e. if the field is not set or has a default value,
|
||||||
|
its size considered to be zero.
|
||||||
|
|
||||||
|
```
|
||||||
|
import "org/signal/chat/require.proto";
|
||||||
|
|
||||||
|
message Data {
|
||||||
|
|
||||||
|
string zip = 1 [(require.exactlySize) = 5];
|
||||||
|
|
||||||
|
optional string exactlySizeVariants = 2 [(require.exactlySize) = 2, (require.exactlySize) = 4];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Applicable to fields of type `string`, `byte`, and `repeated` fields.
|
||||||
|
*/
|
||||||
|
repeated uint32 exactlySize = 70004;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Requires a value of a string field to be a valid E164-normalized phone number.
|
||||||
|
If the field is `optional`, this check allows a value to be not set.
|
||||||
|
|
||||||
|
```
|
||||||
|
import "org/signal/chat/require.proto";
|
||||||
|
|
||||||
|
message Data {
|
||||||
|
string number = 1 [(require.e164)];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
*/
|
||||||
|
optional bool e164 = 70005;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Requires an integer value to be within a certain range. The range bounderies are specified
|
||||||
|
with the values of type `int32`, which should be enough for all practical purposes.
|
||||||
|
|
||||||
|
If the field is `optional`, this check allows a value to be not set.
|
||||||
|
|
||||||
|
```
|
||||||
|
import "org/signal/chat/require.proto";
|
||||||
|
|
||||||
|
message Data {
|
||||||
|
int32 byte = 1 [(require.range) = {min = -128, max = 127}];
|
||||||
|
uint32 unsignedByte = 2 [(require.range).max = 255];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
*/
|
||||||
|
optional ValueRangeConstraint range = 70006;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SizeConstraint {
|
||||||
|
optional uint32 min = 1;
|
||||||
|
optional uint32 max = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ValueRangeConstraint {
|
||||||
|
optional int32 min = 1;
|
||||||
|
optional int32 max = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
extend google.protobuf.ServiceOptions {
|
||||||
|
/*
|
||||||
|
Indicates that all methods in a given service require a certain kind of authentication.
|
||||||
|
|
||||||
|
```
|
||||||
|
import "org/signal/chat/require.proto";
|
||||||
|
|
||||||
|
service AuthService {
|
||||||
|
option (require.auth) = AUTH_ONLY_AUTHENTICATED;
|
||||||
|
|
||||||
|
rpc AuthenticatedMethod (google.protobuf.Empty) returns (google.protobuf.Empty) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
*/
|
||||||
|
optional Auth auth = 71001;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Auth {
|
||||||
|
AUTH_UNSPECIFIED = 0;
|
||||||
|
AUTH_ONLY_AUTHENTICATED = 1;
|
||||||
|
AUTH_ONLY_ANONYMOUS = 2;
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,396 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.grpc;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import com.google.protobuf.ByteString;
|
||||||
|
import com.google.protobuf.Empty;
|
||||||
|
import io.grpc.ServerInterceptors;
|
||||||
|
import io.grpc.Status;
|
||||||
|
import io.grpc.StatusRuntimeException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import org.apache.commons.lang3.RandomStringUtils;
|
||||||
|
import org.apache.commons.lang3.RandomUtils;
|
||||||
|
import org.apache.commons.lang3.tuple.Pair;
|
||||||
|
import org.junit.jupiter.api.Assertions;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||||
|
import org.junit.jupiter.api.function.Executable;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.Arguments;
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import org.signal.chat.require.Auth;
|
||||||
|
import org.signal.chat.rpc.Color;
|
||||||
|
import org.signal.chat.rpc.ReactorAnonymousServiceGrpc;
|
||||||
|
import org.signal.chat.rpc.ReactorAuthServiceGrpc;
|
||||||
|
import org.signal.chat.rpc.ReactorValidationTestServiceGrpc;
|
||||||
|
import org.signal.chat.rpc.ValidationTestServiceGrpc;
|
||||||
|
import org.signal.chat.rpc.ValidationsRequest;
|
||||||
|
import org.signal.chat.rpc.ValidationsResponse;
|
||||||
|
import org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
public class ValidatingInterceptorTest {
|
||||||
|
|
||||||
|
@RegisterExtension
|
||||||
|
static final GrpcServerExtension GRPC_SERVER_EXTENSION = new GrpcServerExtension();
|
||||||
|
|
||||||
|
private static final class ValidationTestGrpcServiceImpl extends ReactorValidationTestServiceGrpc.ValidationTestServiceImplBase {
|
||||||
|
@Override
|
||||||
|
public Mono<ValidationsResponse> validationsEndpoint(final ValidationsRequest request) {
|
||||||
|
return Mono.just(ValidationsResponse.newBuilder().build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class AuthGrpcServiceImpl extends ReactorAuthServiceGrpc.AuthServiceImplBase {
|
||||||
|
@Override
|
||||||
|
public Mono<Empty> authenticatedMethod(final Empty request) {
|
||||||
|
return Mono.just(Empty.getDefaultInstance());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class AnonymousGrpcServiceImpl extends ReactorAnonymousServiceGrpc.AnonymousServiceImplBase {
|
||||||
|
@Override
|
||||||
|
public Mono<Empty> anonymousMethod(final Empty request) {
|
||||||
|
return Mono.just(Empty.getDefaultInstance());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ValidationTestServiceGrpc.ValidationTestServiceBlockingStub stub;
|
||||||
|
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
final ValidationTestGrpcServiceImpl validationTestGrpcService = new ValidationTestGrpcServiceImpl();
|
||||||
|
final AuthGrpcServiceImpl authGrpcService = new AuthGrpcServiceImpl();
|
||||||
|
final AnonymousGrpcServiceImpl anonymousGrpcService = new AnonymousGrpcServiceImpl();
|
||||||
|
|
||||||
|
GRPC_SERVER_EXTENSION.getServiceRegistry()
|
||||||
|
.addService(ServerInterceptors.intercept(validationTestGrpcService, new ValidatingInterceptor()));
|
||||||
|
GRPC_SERVER_EXTENSION.getServiceRegistry()
|
||||||
|
.addService(authGrpcService);
|
||||||
|
GRPC_SERVER_EXTENSION.getServiceRegistry()
|
||||||
|
.addService(anonymousGrpcService);
|
||||||
|
|
||||||
|
stub = ValidationTestServiceGrpc.newBlockingStub(GRPC_SERVER_EXTENSION.getChannel());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"15551234567", "", "123", "+1 555 1234567", "asdf"})
|
||||||
|
public void testE164ValidationFailure(final String invalidNumber) throws Exception {
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setNumber(invalidNumber)
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(ints = {0, 1, 2, 3, 4, 6, 1000})
|
||||||
|
public void testExactlySizeValidationFailure(final int size) throws Exception {
|
||||||
|
final String stringValue = RandomStringUtils.randomAlphanumeric(size);
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setFixedSizeString(stringValue)
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
|
||||||
|
final ByteString byteValue = ByteString.copyFrom(RandomUtils.nextBytes(size));
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setFixedSizeBytes(byteValue)
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
|
||||||
|
final List<String> listValue = IntStream.range(0, size)
|
||||||
|
.mapToObj(i -> RandomStringUtils.randomAlphabetic(10))
|
||||||
|
.toList();
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.clearFixedSizeList()
|
||||||
|
.addAllFixedSizeList(listValue)
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testExactlySizeMultiplePermittedValues() throws Exception {
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setExactlySizeVariants("abc")
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setExactlySizeVariants("")
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.clearExactlySizeVariants()
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setExactlySizeVariants("ab")
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setExactlySizeVariants("abcd")
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Stream<Arguments> testRangeSizeValidationFailure() {
|
||||||
|
return Stream.of(
|
||||||
|
Arguments.of(0, Status.INVALID_ARGUMENT),
|
||||||
|
Arguments.of(1, Status.INVALID_ARGUMENT),
|
||||||
|
Arguments.of(2, Status.INVALID_ARGUMENT),
|
||||||
|
Arguments.of(3, Status.OK),
|
||||||
|
Arguments.of(4, Status.OK),
|
||||||
|
Arguments.of(5, Status.OK),
|
||||||
|
Arguments.of(6, Status.OK),
|
||||||
|
Arguments.of(7, Status.OK),
|
||||||
|
Arguments.of(8, Status.OK),
|
||||||
|
Arguments.of(9, Status.INVALID_ARGUMENT),
|
||||||
|
Arguments.of(1000, Status.INVALID_ARGUMENT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource
|
||||||
|
public void testRangeSizeValidationFailure(final int size, final Status expectedStatus) throws Exception {
|
||||||
|
final String stringValue = RandomStringUtils.randomAlphanumeric(size);
|
||||||
|
assertEquals(expectedStatus.getCode(), requestStatus(() -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setRangeSizeString(stringValue)
|
||||||
|
.build()
|
||||||
|
)).getCode());
|
||||||
|
|
||||||
|
final ByteString byteValue = ByteString.copyFrom(RandomUtils.nextBytes(size));
|
||||||
|
assertEquals(expectedStatus.getCode(), requestStatus(() -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setRangeSizeBytes(byteValue)
|
||||||
|
.build()
|
||||||
|
)).getCode());
|
||||||
|
|
||||||
|
final List<String> listValue = IntStream.range(0, size)
|
||||||
|
.mapToObj(i -> RandomStringUtils.randomAlphabetic(10))
|
||||||
|
.toList();
|
||||||
|
assertEquals(expectedStatus.getCode(), requestStatus(() -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.clearRangeSizeList()
|
||||||
|
.addAllRangeSizeList(listValue)
|
||||||
|
.build()
|
||||||
|
)).getCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testNotOptionalWithMaxLimit() throws Exception {
|
||||||
|
stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.clearWithMaxBytes()
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.clearWithMaxString()
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testNotOptionalWithMinLimit() throws Exception {
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.clearWithMinBytes()
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.clearWithMinString()
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testServiceExtensionValueExtraction() throws Exception {
|
||||||
|
final Map<String, Optional<Auth>> authValues = GRPC_SERVER_EXTENSION.getServiceRegistry().getServices()
|
||||||
|
.stream()
|
||||||
|
.map(sd -> Pair.of(
|
||||||
|
sd.getServiceDescriptor().getName(),
|
||||||
|
ValidatorUtils.serviceAuthExtensionValue(sd)
|
||||||
|
))
|
||||||
|
.collect(Collectors.toMap(Pair::getKey, Pair::getValue));
|
||||||
|
assertEquals(Map.of(
|
||||||
|
"org.signal.chat.rpc.ValidationTestService", Optional.empty(),
|
||||||
|
"org.signal.chat.rpc.AuthService", Optional.of(Auth.AUTH_ONLY_AUTHENTICATED),
|
||||||
|
"org.signal.chat.rpc.AnonymousService", Optional.of(Auth.AUTH_ONLY_ANONYMOUS)
|
||||||
|
), authValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testNonEmpty() throws Exception {
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.clearNonEmptyList()
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
// check not setting a value
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.clearNonEmptyBytes()
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.clearNonEmptyBytesOptional()
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.clearNonEmptyString()
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.clearNonEmptyStringOptional()
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
// now check explicitly setting an empty value
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setNonEmptyBytes(ByteString.EMPTY)
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setNonEmptyBytesOptional(ByteString.EMPTY)
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setNonEmptyString("")
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setNonEmptyStringOptional("")
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testEnumSpecified() throws Exception {
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.clearColor()
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setColor(Color.COLOR_UNSPECIFIED)
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.clearColorOptional()
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setColorOptional(Color.COLOR_UNSPECIFIED)
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testRange() throws Exception {
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setI32(1000)
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setUi32(-1)
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.clearI32Range()
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setI32OptRange(5)
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(
|
||||||
|
builderWithValidDefaults()
|
||||||
|
.setI32OptRange(1000)
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testAllFieldsValidationSuccess() throws Exception {
|
||||||
|
stub.validationsEndpoint(builderWithValidDefaults().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
private static ValidationsRequest.Builder builderWithValidDefaults() {
|
||||||
|
return ValidationsRequest.newBuilder()
|
||||||
|
.setNumber("+15551234567")
|
||||||
|
.setFixedSizeString("12345")
|
||||||
|
.setFixedSizeBytes(ByteString.copyFrom(new byte[5]))
|
||||||
|
.setWithMinBytes(ByteString.copyFrom(new byte[5]))
|
||||||
|
.setWithMaxBytes(ByteString.copyFrom(new byte[5]))
|
||||||
|
.setWithMinString("12345")
|
||||||
|
.setWithMaxString("12345")
|
||||||
|
.setExactlySizeVariants("ab")
|
||||||
|
.setRangeSizeString("abc")
|
||||||
|
.setNonEmptyString("abc")
|
||||||
|
.setNonEmptyStringOptional("abc")
|
||||||
|
.setColor(Color.COLOR_GREEN)
|
||||||
|
.setColorOptional(Color.COLOR_GREEN)
|
||||||
|
.setNonEmptyBytes(ByteString.copyFrom(new byte[5]))
|
||||||
|
.setNonEmptyBytesOptional(ByteString.copyFrom(new byte[5]))
|
||||||
|
.addAllNonEmptyList(List.of("a", "b", "c", "d", "e"))
|
||||||
|
.setRangeSizeBytes(ByteString.copyFrom(new byte[3]))
|
||||||
|
.addAllFixedSizeList(List.of("a", "b", "c", "d", "e"))
|
||||||
|
.addAllRangeSizeList(List.of("a", "b", "c", "d", "e"))
|
||||||
|
.setI32Range(15);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertStatusException(final Status expected, final Executable serviceCall) {
|
||||||
|
final StatusRuntimeException exception = Assertions.assertThrows(StatusRuntimeException.class, serviceCall);
|
||||||
|
assertEquals(expected.getCode(), exception.getStatus().getCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Status requestStatus(final Runnable runnable) {
|
||||||
|
try {
|
||||||
|
runnable.run();
|
||||||
|
return Status.OK;
|
||||||
|
} catch (final StatusRuntimeException e) {
|
||||||
|
return e.getStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,80 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
option java_multiple_files = true;
|
||||||
|
|
||||||
|
package org.signal.chat.rpc;
|
||||||
|
|
||||||
|
import "org/signal/chat/require.proto";
|
||||||
|
import "google/protobuf/empty.proto";
|
||||||
|
|
||||||
|
service ValidationTestService {
|
||||||
|
rpc ValidationsEndpoint (ValidationsRequest) returns (ValidationsResponse) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
service AuthService {
|
||||||
|
option (require.auth) = AUTH_ONLY_AUTHENTICATED;
|
||||||
|
|
||||||
|
rpc AuthenticatedMethod (google.protobuf.Empty) returns (google.protobuf.Empty) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
service AnonymousService {
|
||||||
|
option (require.auth) = AUTH_ONLY_ANONYMOUS;
|
||||||
|
|
||||||
|
rpc AnonymousMethod (google.protobuf.Empty) returns (google.protobuf.Empty) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
message ValidationsRequest {
|
||||||
|
optional string number = 1 [(require.e164) = true];
|
||||||
|
|
||||||
|
optional string fixedSizeString = 2 [(require.exactlySize) = 5];
|
||||||
|
optional string rangeSizeString = 3 [(require.size) = {min: 3, max: 8}];
|
||||||
|
|
||||||
|
optional bytes fixedSizeBytes = 4 [(require.exactlySize) = 5];
|
||||||
|
optional bytes rangeSizeBytes = 5 [(require.size) = {min: 3, max: 8}];
|
||||||
|
|
||||||
|
repeated string fixedSizeList = 6 [(require.exactlySize) = 5];
|
||||||
|
repeated string rangeSizeList = 7 [(require.size) = {min: 3, max: 8}];
|
||||||
|
|
||||||
|
bytes withMinBytes = 8 [(require.size).min = 3];
|
||||||
|
string withMinString = 9 [(require.size).min = 3];
|
||||||
|
|
||||||
|
bytes withMaxBytes = 10 [(require.size).max = 8];
|
||||||
|
string withMaxString = 11 [(require.size).max = 8];
|
||||||
|
|
||||||
|
optional string exactlySizeVariants = 12 [(require.exactlySize) = 2, (require.exactlySize) = 4];
|
||||||
|
optional string exactlySizeVariantsWithZero = 13 [(require.exactlySize) = 0, (require.exactlySize) = 4];
|
||||||
|
|
||||||
|
optional string nonEmptyStringOptional = 14 [(require.nonEmpty) = true];
|
||||||
|
optional bytes nonEmptyBytesOptional = 15 [(require.nonEmpty) = true];
|
||||||
|
string nonEmptyString = 16 [(require.nonEmpty) = true];
|
||||||
|
bytes nonEmptyBytes = 17 [(require.nonEmpty) = true];
|
||||||
|
repeated string nonEmptyList = 18 [(require.nonEmpty) = true];
|
||||||
|
|
||||||
|
optional Color colorOptional = 19 [(require.specified) = true];
|
||||||
|
Color color = 20 [(require.specified) = true];
|
||||||
|
|
||||||
|
int32 i32 = 21 [(require.range).max = 100];
|
||||||
|
uint32 ui32 = 22 [(require.range).max = 100];
|
||||||
|
int32 i32range = 23 [(require.range) = {min: 10, max: 20}];
|
||||||
|
optional int32 i32OptRange = 24 [(require.range) = {min: 10, max: 20}];
|
||||||
|
}
|
||||||
|
|
||||||
|
message MessageWithInvalidRangeConstraint {
|
||||||
|
int32 i32 = 1 [(require.range) = {min: 10, max: 5}];
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Color {
|
||||||
|
COLOR_UNSPECIFIED = 0;
|
||||||
|
COLOR_RED = 1;
|
||||||
|
COLOR_GREEN = 2;
|
||||||
|
COLOR_BLUE = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ValidationsResponse {
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue