Accommodate gRPC in the `SpamChecker` interface

This commit is contained in:
Jon Chambers 2025-04-02 13:16:55 -04:00 committed by GitHub
parent 488e7c4913
commit 7ea0885474
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 187 additions and 32 deletions

View File

@ -103,6 +103,8 @@ import org.whispersystems.textsecuregcm.push.MessageTooLargeException;
import org.whispersystems.textsecuregcm.push.PushNotificationManager;
import org.whispersystems.textsecuregcm.push.PushNotificationScheduler;
import org.whispersystems.textsecuregcm.push.ReceiptSender;
import org.whispersystems.textsecuregcm.spam.MessageType;
import org.whispersystems.textsecuregcm.spam.SpamCheckResult;
import org.whispersystems.textsecuregcm.spam.SpamChecker;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
@ -303,14 +305,27 @@ public class MessageController {
maybeDestination = source.map(AuthenticatedDevice::getAccount);
}
final SpamChecker.SpamCheckResult spamCheck = spamChecker.checkForSpam(
context, source, maybeDestination, Optional.of(destinationIdentifier));
final Optional<byte[]> reportSpamToken;
switch (spamCheck) {
case final SpamChecker.Spam spam: return spam.response();
case final SpamChecker.NotSpam notSpam: reportSpamToken = notSpam.token();
final MessageType messageType;
if (isStory) {
messageType = MessageType.INDIVIDUAL_STORY;
} else if (isSyncMessage) {
messageType = MessageType.SYNC;
} else if (source.isPresent()) {
messageType = MessageType.INDIVIDUAL_IDENTIFIED_SENDER;
} else {
messageType = MessageType.INDIVIDUAL_SEALED_SENDER;
}
final SpamCheckResult<Response> spamCheckResult =
spamChecker.checkForIndividualRecipientSpamHttp(messageType, context, source, maybeDestination, Optional.of(destinationIdentifier));
if (spamCheckResult.response().isPresent()) {
return spamCheckResult.response().get();
}
final Optional<byte[]> reportSpamToken = spamCheckResult.token();
int totalContentLength = 0;
for (final IncomingMessage message : messages.messages()) {
@ -534,9 +549,12 @@ public class MessageController {
}
}
final SpamChecker.SpamCheckResult spamCheck = spamChecker.checkForSpam(context, Optional.empty(), Optional.empty(), Optional.empty());
if (spamCheck instanceof final SpamChecker.Spam spam) {
return spam.response();
final SpamCheckResult<Response> spamCheckResult = spamChecker.checkForMultiRecipientSpamHttp(
isStory ? MessageType.MULTI_RECIPIENT_STORY : MessageType.MULTI_RECIPIENT_SEALED_SENDER,
context);
if (spamCheckResult.response().isPresent()) {
return spamCheckResult.response().get();
}
if (groupSendToken == null && accessKeys == null && !isStory) {

View File

@ -0,0 +1,48 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.spam;
import io.grpc.Status;
import java.util.Optional;
/**
* A combination of a gRPC status and response message to communicate to callers that a message has been flagged as
* potential spam.
*
* @param status The gRPC status for this response. If the status is {@link Status#OK}, then a response object will be
* available via {@link #response}. Otherwise, callers should transmit the status as an error to clients.
* @param response a response object to send to clients; will be present if {@link #status} is not {@link Status#OK}
*
* @param <R> the type of response object
*/
public record GrpcResponse<R>(Status status, Optional<R> response) {
/**
* Constructs a new response object with the given status and no response message.
*
* @param status the status to send to callers
*
* @return a new response object with the given status and no response message
*
* @param <R> the type of response object
*/
public static <R> GrpcResponse<R> withStatus(final Status status) {
return new GrpcResponse<>(status, Optional.empty());
}
/**
* Constructs a new response object with a status of {@link Status#OK} and the given response message.
*
* @param response the response to send to the caller
*
* @return a new response object with a status of {@link Status#OK} and the given response message
*
* @param <R> the type of response object
*/
public static <R> GrpcResponse<R> withResponse(final R response) {
return new GrpcResponse<>(Status.OK, Optional.of(response));
}
}

View File

@ -0,0 +1,15 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.spam;
public enum MessageType {
INDIVIDUAL_IDENTIFIED_SENDER,
SYNC,
INDIVIDUAL_SEALED_SENDER,
MULTI_RECIPIENT_SEALED_SENDER,
INDIVIDUAL_STORY,
MULTI_RECIPIENT_STORY,
}

View File

@ -0,0 +1,21 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.spam;
import java.util.Optional;
/**
* The result of a spam check. May contain a response to relay to the caller if a message was identified as potential
* spam or a spam reporting token to include in the delivered message.
*
* @param response a transport-appropriate response to return to the sender if the message was identified as potential
* spam, or empty if processing should continue as normal
* @param token a spam-reporting token to include in the outbound message, or empty if no token applies to the message
*
* @param <T> the type of response for messages identified as potential spam
*/
public record SpamCheckResult<T>(Optional<T> response, Optional<byte[]> token) {
}

View File

@ -5,35 +5,19 @@
package org.whispersystems.textsecuregcm.spam;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.Response;
import java.util.Optional;
import jakarta.ws.rs.core.Response;
import org.signal.chat.messages.SendMessageResponse;
import org.signal.chat.messages.SendMultiRecipientMessageResponse;
import org.whispersystems.textsecuregcm.auth.AccountAndAuthenticatedDeviceHolder;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.storage.Account;
public interface SpamChecker {
/**
* A result from the spam checker that is one of:
* <ul>
* <li>
* Message is determined to be spam, and a response is returned
* </li>
* <li>
* Message is not spam, and an optional spam token is returned
* </li>
* </ul>
*/
sealed interface SpamCheckResult {}
record Spam(Response response) implements SpamCheckResult {}
record NotSpam(Optional<byte[]> token) implements SpamCheckResult {
public static final NotSpam EMPTY_TOKEN = new NotSpam(Optional.empty());
}
/**
* Determine if a message may be spam
* Determine if a message sent to an individual recipient via HTTP may be spam.
*
* @param requestContext The request context for a message send attempt
* @param maybeSource The sender of the message, could be empty if this as message sent with sealed sender
@ -41,13 +25,82 @@ public interface SpamChecker {
* not be retrieved
* @return A {@link SpamCheckResult}
*/
SpamCheckResult checkForSpam(
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
SpamCheckResult<Response> checkForIndividualRecipientSpamHttp(
final MessageType messageType,
final ContainerRequestContext requestContext,
final Optional<? extends AccountAndAuthenticatedDeviceHolder> maybeSource,
final Optional<Account> maybeDestination,
final Optional<ServiceIdentifier> maybeDestinationIdentifier);
/**
* Determine if a message sent to multiple recipients via HTTP may be spam.
*
* @param requestContext The request context for a message send attempt
* @return A {@link SpamCheckResult}
*/
SpamCheckResult<Response> checkForMultiRecipientSpamHttp(
final MessageType messageType,
final ContainerRequestContext requestContext);
/**
* Determine if a message sent to an individual recipient via gRPC may be spam.
*
* @param maybeSource The sender of the message, could be empty if this as message sent with sealed sender
* @param maybeDestination The destination of the message, could be empty if the destination does not exist or could
* not be retrieved
* @return A {@link SpamCheckResult}
*/
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
SpamCheckResult<GrpcResponse<SendMessageResponse>> checkForIndividualRecipientSpamGrpc(
final MessageType messageType,
final Optional<org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice> maybeSource,
final Optional<Account> maybeDestination,
final Optional<ServiceIdentifier> maybeDestinationIdentifier);
/**
* Determine if a message sent to multiple recipients via gRPC may be spam.
*
* @return A {@link SpamCheckResult}
*/
SpamCheckResult<GrpcResponse<SendMultiRecipientMessageResponse>> checkForMultiRecipientSpamGrpc(final MessageType messageType);
static SpamChecker noop() {
return (ignoredContext, ignoredSource, ignoredDestination, ignoredDestinationIdentifier) -> NotSpam.EMPTY_TOKEN;
return new SpamChecker() {
@Override
public SpamCheckResult<Response> checkForIndividualRecipientSpamHttp(final MessageType messageType,
final ContainerRequestContext requestContext,
final Optional<? extends AccountAndAuthenticatedDeviceHolder> maybeSource,
final Optional<Account> maybeDestination,
final Optional<ServiceIdentifier> maybeDestinationIdentifier) {
return new SpamCheckResult<>(Optional.empty(), Optional.empty());
}
@Override
public SpamCheckResult<Response> checkForMultiRecipientSpamHttp(final MessageType messageType,
final ContainerRequestContext requestContext) {
return new SpamCheckResult<>(Optional.empty(), Optional.empty());
}
@Override
public SpamCheckResult<GrpcResponse<SendMessageResponse>> checkForIndividualRecipientSpamGrpc(final MessageType messageType,
final Optional<AuthenticatedDevice> maybeSource,
final Optional<Account> maybeDestination,
final Optional<ServiceIdentifier> maybeDestinationIdentifier) {
return new SpamCheckResult<>(Optional.empty(), Optional.empty());
}
@Override
public SpamCheckResult<GrpcResponse<SendMultiRecipientMessageResponse>> checkForMultiRecipientSpamGrpc(
final MessageType messageType) {
return new SpamCheckResult<>(Optional.empty(), Optional.empty());
}
};
}
}