From 7ea0885474a43210e260b21f4e87bea776b57b53 Mon Sep 17 00:00:00 2001 From: Jon Chambers <63609320+jon-signal@users.noreply.github.com> Date: Wed, 2 Apr 2025 13:16:55 -0400 Subject: [PATCH] Accommodate gRPC in the `SpamChecker` interface --- .../controllers/MessageController.java | 36 +++++-- .../textsecuregcm/spam/GrpcResponse.java | 48 +++++++++ .../textsecuregcm/spam/MessageType.java | 15 +++ .../textsecuregcm/spam/SpamCheckResult.java | 21 ++++ .../textsecuregcm/spam/SpamChecker.java | 99 ++++++++++++++----- 5 files changed, 187 insertions(+), 32 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/spam/GrpcResponse.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/spam/MessageType.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/spam/SpamCheckResult.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/MessageController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/MessageController.java index 0a505ee7e..a952651fc 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/MessageController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/MessageController.java @@ -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 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 spamCheckResult = + spamChecker.checkForIndividualRecipientSpamHttp(messageType, context, source, maybeDestination, Optional.of(destinationIdentifier)); + + if (spamCheckResult.response().isPresent()) { + return spamCheckResult.response().get(); + } + + final Optional 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 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) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/spam/GrpcResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/spam/GrpcResponse.java new file mode 100644 index 000000000..b9cac74df --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/spam/GrpcResponse.java @@ -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 the type of response object + */ +public record GrpcResponse(Status status, Optional 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 the type of response object + */ + public static GrpcResponse 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 the type of response object + */ + public static GrpcResponse withResponse(final R response) { + return new GrpcResponse<>(Status.OK, Optional.of(response)); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/spam/MessageType.java b/service/src/main/java/org/whispersystems/textsecuregcm/spam/MessageType.java new file mode 100644 index 000000000..70fd75320 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/spam/MessageType.java @@ -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, +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/spam/SpamCheckResult.java b/service/src/main/java/org/whispersystems/textsecuregcm/spam/SpamCheckResult.java new file mode 100644 index 000000000..b89c0f0e8 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/spam/SpamCheckResult.java @@ -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 the type of response for messages identified as potential spam + */ +public record SpamCheckResult(Optional response, Optional token) { +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/spam/SpamChecker.java b/service/src/main/java/org/whispersystems/textsecuregcm/spam/SpamChecker.java index 48b958972..53921acd2 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/spam/SpamChecker.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/spam/SpamChecker.java @@ -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: - *
    - *
  • - * Message is determined to be spam, and a response is returned - *
  • - *
  • - * Message is not spam, and an optional spam token is returned - *
  • - *
- */ - sealed interface SpamCheckResult {} - - record Spam(Response response) implements SpamCheckResult {} - - record NotSpam(Optional 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 checkForIndividualRecipientSpamHttp( + final MessageType messageType, final ContainerRequestContext requestContext, final Optional maybeSource, final Optional maybeDestination, final Optional 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 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> checkForIndividualRecipientSpamGrpc( + final MessageType messageType, + final Optional maybeSource, + final Optional maybeDestination, + final Optional maybeDestinationIdentifier); + + /** + * Determine if a message sent to multiple recipients via gRPC may be spam. + * + * @return A {@link SpamCheckResult} + */ + SpamCheckResult> checkForMultiRecipientSpamGrpc(final MessageType messageType); + + static SpamChecker noop() { - return (ignoredContext, ignoredSource, ignoredDestination, ignoredDestinationIdentifier) -> NotSpam.EMPTY_TOKEN; + return new SpamChecker() { + + @Override + public SpamCheckResult checkForIndividualRecipientSpamHttp(final MessageType messageType, + final ContainerRequestContext requestContext, + final Optional maybeSource, + final Optional maybeDestination, + final Optional maybeDestinationIdentifier) { + + return new SpamCheckResult<>(Optional.empty(), Optional.empty()); + } + + @Override + public SpamCheckResult checkForMultiRecipientSpamHttp(final MessageType messageType, + final ContainerRequestContext requestContext) { + + return new SpamCheckResult<>(Optional.empty(), Optional.empty()); + } + + @Override + public SpamCheckResult> checkForIndividualRecipientSpamGrpc(final MessageType messageType, + final Optional maybeSource, + final Optional maybeDestination, + final Optional maybeDestinationIdentifier) { + + return new SpamCheckResult<>(Optional.empty(), Optional.empty()); + } + + @Override + public SpamCheckResult> checkForMultiRecipientSpamGrpc( + final MessageType messageType) { + + return new SpamCheckResult<>(Optional.empty(), Optional.empty()); + } + }; } }