diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index b122ee0c5..00cb5ea4b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -31,6 +31,7 @@ import org.whispersystems.textsecuregcm.configuration.DeviceCheckConfiguration; import org.whispersystems.textsecuregcm.configuration.DirectoryV2Configuration; import org.whispersystems.textsecuregcm.configuration.DynamoDbClientFactory; import org.whispersystems.textsecuregcm.configuration.DynamoDbTables; +import org.whispersystems.textsecuregcm.configuration.GrpcAllowListConfiguration; import org.whispersystems.textsecuregcm.configuration.ExternalRequestFilterConfiguration; import org.whispersystems.textsecuregcm.configuration.FaultTolerantRedisClientFactory; import org.whispersystems.textsecuregcm.configuration.FaultTolerantRedisClusterFactory; @@ -348,6 +349,11 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private GrpcConfiguration grpc; + @NotNull + @Valid + @JsonProperty + private GrpcAllowListConfiguration grpcAllowList = new GrpcAllowListConfiguration(); + @Valid @NotNull @JsonProperty @@ -589,6 +595,10 @@ public class WhisperServerConfiguration extends Configuration { return grpc; } + public GrpcAllowListConfiguration getGrpcAllowList() { + return grpcAllowList; + } + public S3ObjectMonitorFactory getAsnTableConfiguration() { return asnTable; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 73181efd3..688d85e57 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -143,6 +143,7 @@ import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter; import org.whispersystems.textsecuregcm.grpc.AccountsAnonymousGrpcService; import org.whispersystems.textsecuregcm.grpc.AccountsGrpcService; import org.whispersystems.textsecuregcm.grpc.CallQualitySurveyGrpcService; +import org.whispersystems.textsecuregcm.grpc.GrpcAllowListInterceptor; import org.whispersystems.textsecuregcm.grpc.ErrorMappingInterceptor; import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsAnonymousGrpcService; import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsGrpcService; @@ -209,7 +210,6 @@ import org.whispersystems.textsecuregcm.s3.S3MonitoringSupplier; import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient; import org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryClient; import org.whispersystems.textsecuregcm.spam.ChallengeConstraintChecker; -import org.whispersystems.textsecuregcm.spam.MessageDeliveryListener; import org.whispersystems.textsecuregcm.spam.RegistrationFraudChecker; import org.whispersystems.textsecuregcm.spam.RegistrationRecoveryChecker; import org.whispersystems.textsecuregcm.spam.SpamChecker; @@ -877,6 +877,8 @@ public class WhisperServerService extends Application enabledServices, + List enabledMethods) { + + public GrpcAllowListConfiguration { + if (enabledServices == null) { + enabledServices = Collections.emptyList(); + } + if (enabledMethods == null) { + enabledMethods = Collections.emptyList(); + } + } + + public GrpcAllowListConfiguration() { + // By default, no GRPC methods are accessible + this(false, Collections.emptyList(), Collections.emptyList()); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/GrpcAllowListInterceptor.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/GrpcAllowListInterceptor.java new file mode 100644 index 000000000..a4b43a750 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/GrpcAllowListInterceptor.java @@ -0,0 +1,42 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.grpc; + +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.Status; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class GrpcAllowListInterceptor implements ServerInterceptor { + + private final boolean enableAll; + private final Set enabledServices; + private final Set enabledMethods; + + + public GrpcAllowListInterceptor( + final boolean enableAll, + final List enabledServices, + final List enabledMethods) { + this.enableAll = enableAll; + this.enabledServices = new HashSet<>(enabledServices); + this.enabledMethods = new HashSet<>(enabledMethods); + } + + @Override + public ServerCall.Listener interceptCall(final ServerCall serverCall, + final Metadata metadata, final ServerCallHandler next) { + final MethodDescriptor methodDescriptor = serverCall.getMethodDescriptor(); + if (!enableAll && !enabledServices.contains(methodDescriptor.getServiceName()) && !enabledMethods.contains(methodDescriptor.getFullMethodName())) { + return ServerInterceptorUtil.closeWithStatus(serverCall, Status.UNIMPLEMENTED); + } + return next.startCall(serverCall, metadata); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/GrpcAllowListInterceptorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/GrpcAllowListInterceptorTest.java new file mode 100644 index 000000000..c1c60643c --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/GrpcAllowListInterceptorTest.java @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.grpc; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.protobuf.ByteString; +import io.grpc.ManagedChannel; +import io.grpc.Server; +import io.grpc.Status; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.signal.chat.rpc.EchoRequest; +import org.signal.chat.rpc.EchoResponse; +import org.signal.chat.rpc.EchoServiceGrpc; + +class GrpcAllowListInterceptorTest { + private Server server; + private ManagedChannel channel; + + @BeforeEach + void setUp() { + channel = InProcessChannelBuilder.forName("GrpcAllowListInterceptorTest") + .directExecutor() + .build(); + } + + @AfterEach + void tearDown() throws Exception { + server.shutdownNow(); + channel.shutdownNow(); + server.awaitTermination(1, TimeUnit.SECONDS); + channel.awaitTermination(1, TimeUnit.SECONDS); + } + + @Test + public void disableAll() throws Exception { + final EchoServiceGrpc.EchoServiceBlockingStub client = + setup(false, Collections.emptyList(), Collections.emptyList()); + GrpcTestUtils.assertStatusException(Status.UNIMPLEMENTED, () -> + client.echo(EchoRequest.newBuilder().setPayload(ByteString.empty()).build())); + } + + @Test + public void enableAll() throws Exception { + final EchoServiceGrpc.EchoServiceBlockingStub client = + setup(true, Collections.emptyList(), Collections.emptyList()); + final EchoResponse echo = client.echo(EchoRequest.newBuilder().setPayload(ByteString.empty()).build()); + assertThat(echo.getPayload()).isEqualTo(ByteString.empty()); + } + + @Test + public void enableByMethod() throws Exception { + final EchoServiceGrpc.EchoServiceBlockingStub client = + setup(false, Collections.emptyList(), List.of("org.signal.chat.rpc.EchoService/echo")); + + final EchoResponse echo = client.echo(EchoRequest.newBuilder().setPayload(ByteString.empty()).build()); + assertThat(echo.getPayload()).isEqualTo(ByteString.empty()); + + GrpcTestUtils.assertStatusException(Status.UNIMPLEMENTED, () -> + client.echo2(EchoRequest.newBuilder().setPayload(ByteString.empty()).build())); + } + + @Test + public void enableByService() throws Exception { + final EchoServiceGrpc.EchoServiceBlockingStub client = + setup(false, List.of("org.signal.chat.rpc.EchoService"), Collections.emptyList()); + + final EchoResponse echo = client.echo(EchoRequest.newBuilder().setPayload(ByteString.empty()).build()); + assertThat(echo.getPayload()).isEqualTo(ByteString.empty()); + + final EchoResponse echo2 = client.echo2(EchoRequest.newBuilder().setPayload(ByteString.empty()).build()); + assertThat(echo2.getPayload()).isEqualTo(ByteString.empty()); + } + + @Test + public void enableByServiceWrongService() throws Exception { + final EchoServiceGrpc.EchoServiceBlockingStub client = + setup(false, List.of("org.signal.chat.rpc.NotEchoService"), Collections.emptyList()); + + GrpcTestUtils.assertStatusException(Status.UNIMPLEMENTED, () -> + client.echo(EchoRequest.newBuilder().setPayload(ByteString.empty()).build())); + } + + private EchoServiceGrpc.EchoServiceBlockingStub setup( + boolean enableAll, + List enabledServices, + List enabledMethods) + throws IOException { + if (server != null) { + server.shutdownNow(); + } + server = InProcessServerBuilder.forName("GrpcAllowListInterceptorTest") + .directExecutor() + .addService(new EchoServiceImpl()) + .intercept(new GrpcAllowListInterceptor(enableAll, enabledServices, enabledMethods)) + .build() + .start(); + + return EchoServiceGrpc.newBlockingStub(channel); + } +} diff --git a/service/src/test/resources/config/test.yml b/service/src/test/resources/config/test.yml index ee5f6fb4a..ede6c3e34 100644 --- a/service/src/test/resources/config/test.yml +++ b/service/src/test/resources/config/test.yml @@ -517,6 +517,9 @@ idlePrimaryDeviceReminder: grpc: port: 50051 +grpcAllowList: + enableAll: true + asnTable: s3Region: a-region s3Bucket: a-bucket