diff --git a/service/config/sample.yml b/service/config/sample.yml index 65e41fc13..8eab1952b 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -472,3 +472,11 @@ callingTurnManualTable: noiseTunnel: port: 8443 recognizedProxySecret: secret://noiseTunnel.recognizedProxySecret + +externalRequestFilter: + grpcMethods: + - com.example.grpc.ExampleService/exampleMethod + paths: + - /example + permittedInternalRanges: + - 127.0.0.0/8 diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index 9ae54f59b..a167bf47c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -30,6 +30,7 @@ import org.whispersystems.textsecuregcm.configuration.DirectoryV2Configuration; import org.whispersystems.textsecuregcm.configuration.DogstatsdConfiguration; import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguration; import org.whispersystems.textsecuregcm.configuration.DynamoDbTables; +import org.whispersystems.textsecuregcm.configuration.ExternalRequestFilterConfiguration; import org.whispersystems.textsecuregcm.configuration.FcmConfiguration; import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration; import org.whispersystems.textsecuregcm.configuration.GenericZkConfig; @@ -339,6 +340,11 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private NoiseWebSocketTunnelConfiguration noiseTunnel; + @Valid + @NotNull + @JsonProperty + private ExternalRequestFilterConfiguration externalRequestFilter; + public TlsKeyStoreConfiguration getTlsKeyStoreConfiguration() { return tlsKeyStore; } @@ -565,4 +571,8 @@ public class WhisperServerConfiguration extends Configuration { public NoiseWebSocketTunnelConfiguration getNoiseWebSocketTunnelConfiguration() { return noiseTunnel; } + + public ExternalRequestFilterConfiguration getExternalRequestFilterConfiguration() { + return externalRequestFilter; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 7c92e0000..02515bd0c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -123,6 +123,7 @@ import org.whispersystems.textsecuregcm.currency.CoinMarketCapClient; import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager; import org.whispersystems.textsecuregcm.currency.FixerClient; import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; +import org.whispersystems.textsecuregcm.filters.ExternalRequestFilter; import org.whispersystems.textsecuregcm.filters.RemoteAddressFilter; import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter; import org.whispersystems.textsecuregcm.filters.RequestStatisticsFilter; @@ -778,6 +779,9 @@ public class WhisperServerService extends Application accountAuthFilter = new BasicCredentialAuthFilter.Builder() .setAuthenticator(accountAuthenticator) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ExternalRequestFilterConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ExternalRequestFilterConfiguration.java new file mode 100644 index 000000000..ff231d347 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ExternalRequestFilterConfiguration.java @@ -0,0 +1,16 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.configuration; + +import java.util.Set; +import javax.validation.Valid; +import javax.validation.constraints.NotNull; +import org.whispersystems.textsecuregcm.util.InetAddressRange; + +public record ExternalRequestFilterConfiguration(@Valid @NotNull Set<@NotNull String> paths, + @Valid @NotNull Set<@NotNull InetAddressRange> permittedInternalRanges, + @Valid @NotNull Set<@NotNull String> grpcMethods) { +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/filters/ExternalRequestFilter.java b/service/src/main/java/org/whispersystems/textsecuregcm/filters/ExternalRequestFilter.java new file mode 100644 index 000000000..963d0cf3f --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/filters/ExternalRequestFilter.java @@ -0,0 +1,101 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.filters; + +import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; + +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 io.micrometer.core.instrument.Metrics; +import java.io.IOException; +import java.net.InetAddress; +import java.util.Set; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.grpc.RequestAttributesUtil; +import org.whispersystems.textsecuregcm.util.InetAddressRange; + +public class ExternalRequestFilter implements Filter, ServerInterceptor { + + private static final Logger logger = LoggerFactory.getLogger(ExternalRequestFilter.class); + + private static final String REQUESTS_COUNTER_NAME = name(ExternalRequestFilter.class, "requests"); + private static final String PROTOCOL_TAG_NAME = "protocol"; + private static final String BLOCKED_TAG_NAME = "blocked"; + + private final Set permittedInternalAddressRanges; + private final Set filteredGrpcMethodNames; + + public ExternalRequestFilter(final Set permittedInternalAddressRanges, + final Set filteredGrpcMethodNames) { + this.permittedInternalAddressRanges = permittedInternalAddressRanges; + this.filteredGrpcMethodNames = filteredGrpcMethodNames; + } + + @Override + public ServerCall.Listener interceptCall(final ServerCall call, + final Metadata headers, final ServerCallHandler next) { + + final MethodDescriptor methodDescriptor = call.getMethodDescriptor(); + final boolean shouldFilterMethod = filteredGrpcMethodNames.contains(methodDescriptor.getFullMethodName()); + + final InetAddress remoteAddress = RequestAttributesUtil.getRemoteAddress(); + final boolean blocked = shouldFilterMethod && shouldBlock(remoteAddress); + + Metrics.counter(REQUESTS_COUNTER_NAME, + PROTOCOL_TAG_NAME, "grpc", + BLOCKED_TAG_NAME, String.valueOf(blocked)) + .increment(); + + if (blocked) { + call.close(Status.NOT_FOUND, new Metadata()); + return new ServerCall.Listener<>() {}; + } + + return next.startCall(call, headers); + } + + @Override + public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain) + throws IOException, ServletException { + + final InetAddress remoteInetAddress = InetAddress.getByName( + (String) request.getAttribute(RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME)); + final boolean restricted = shouldBlock(remoteInetAddress); + + Metrics.counter(REQUESTS_COUNTER_NAME, + PROTOCOL_TAG_NAME, "http", + BLOCKED_TAG_NAME, String.valueOf(restricted)) + .increment(); + + if (restricted) { + if (response instanceof HttpServletResponse hsr) { + hsr.setStatus(404); + } else { + logger.warn("response was an unexpected type: {}", response.getClass()); + } + return; + } + + chain.doFilter(request, response); + } + + public boolean shouldBlock(InetAddress remoteAddress) { + return permittedInternalAddressRanges.stream() + .noneMatch(range -> range.contains(remoteAddress)); + } + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/InetAddressRange.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/InetAddressRange.java new file mode 100644 index 000000000..aeda0bdff --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/InetAddressRange.java @@ -0,0 +1,103 @@ +/* + * Copyright 2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.util; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.net.InetAddresses; +import java.net.InetAddress; +import java.util.Arrays; + +/** + * An InetAddressRange represents a contiguous range of IPv4 or IPv6 addresses. + */ +public class InetAddressRange { + + private final InetAddress networkAddress; + + private final byte[] networkAddressBytes; + private final byte[] prefixMask; + + public InetAddressRange(final String cidrBlock) { + final String[] components = cidrBlock.split("/"); + + if (components.length != 2) { + throw new IllegalArgumentException("Unexpected CIDR block notation: " + cidrBlock); + } + + final int prefixLength; + + try { + networkAddress = InetAddresses.forString(components[0]); + prefixLength = Integer.parseInt(components[1]); + + if (prefixLength > networkAddress.getAddress().length * 8) { + throw new IllegalArgumentException("Prefix length cannot exceed length of address"); + } + } catch (final NumberFormatException e) { + throw new IllegalArgumentException("Bad prefix length: " + components[1]); + } + + networkAddressBytes = networkAddress.getAddress(); + prefixMask = generatePrefixMask(networkAddressBytes.length, prefixLength); + } + + @VisibleForTesting + static byte[] generatePrefixMask(final int addressLengthBytes, final int prefixLengthBits) { + final byte[] prefixMask = new byte[addressLengthBytes]; + + for (int i = 0; i < addressLengthBytes; i++) { + final int bitsAvailable = Math.min(8, Math.max(0, prefixLengthBits - (i * 8))); + prefixMask[i] = (byte) (0xff << (8 - bitsAvailable)); + } + + return prefixMask; + } + + public boolean contains(final String name) { + // InetAddresses.forString() throws "IllegalArgumentException" for anything that is not an IP address + return contains(InetAddresses.forString(name)); + } + + public boolean contains(final InetAddress inetAddress) { + if (!networkAddress.getClass().equals(inetAddress.getClass())) { + return false; + } + + final byte[] addressBytes = inetAddress.getAddress(); + + for (int i = 0; i < addressBytes.length; i++) { + if (((addressBytes[i] ^ networkAddressBytes[i]) & prefixMask[i]) != 0) { + return false; + } + } + + return true; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + final InetAddressRange that = (InetAddressRange) o; + + if (!networkAddress.equals(that.networkAddress)) { + return false; + } + return Arrays.equals(prefixMask, that.prefixMask); + } + + @Override + public int hashCode() { + int result = networkAddress.hashCode(); + result = 31 * result + Arrays.hashCode(prefixMask); + return result; + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/filters/ExternalRequestFilterTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/filters/ExternalRequestFilterTest.java new file mode 100644 index 000000000..031ef882e --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/filters/ExternalRequestFilterTest.java @@ -0,0 +1,245 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.filters; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.google.protobuf.ByteString; +import io.dropwizard.core.Application; +import io.dropwizard.core.Configuration; +import io.dropwizard.core.setup.Environment; +import io.dropwizard.testing.DropwizardTestSupport; +import io.dropwizard.testing.junit5.DropwizardAppExtension; +import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; +import io.grpc.ManagedChannel; +import io.grpc.Server; +import io.grpc.Status; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import java.net.InetAddress; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import javax.servlet.DispatcherType; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.client.Client; +import javax.ws.rs.core.Response; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.signal.chat.rpc.EchoRequest; +import org.signal.chat.rpc.EchoServiceGrpc; +import org.whispersystems.textsecuregcm.grpc.EchoServiceImpl; +import org.whispersystems.textsecuregcm.grpc.GrpcTestUtils; +import org.whispersystems.textsecuregcm.grpc.MockRequestAttributesInterceptor; +import org.whispersystems.textsecuregcm.util.InetAddressRange; + +@ExtendWith(DropwizardExtensionsSupport.class) +class ExternalRequestFilterTest { + + @Nested + class Allowed extends TestCase { + + @Override + DropwizardTestSupport getTestSupport() { + return new DropwizardTestSupport<>(TestApplication.class, getConfiguration()); + } + + @Override + int getExpectedHttpStatus() { + return 200; + } + + @Override + Status getExpectedGrpcStatus() { + return Status.OK; + } + + @Override + TestConfiguration getConfiguration() { + return new TestConfiguration() { + @Override + public Set getPermittedRanges() { + return Set.of(new InetAddressRange("127.0.0.0/8")); + } + }; + } + + } + + @Nested + class Blocked extends TestCase { + + @Override + DropwizardTestSupport getTestSupport() { + return new DropwizardTestSupport<>(TestApplication.class, getConfiguration()); + } + + @Override + int getExpectedHttpStatus() { + return 404; + } + + @Override + Status getExpectedGrpcStatus() { + return Status.NOT_FOUND; + } + + @Override + TestConfiguration getConfiguration() { + return new TestConfiguration() { + @Override + public Set getPermittedRanges() { + return Set.of(new InetAddressRange("10.0.0.0/8")); + } + }; + } + } + + abstract static class TestCase { + + abstract DropwizardTestSupport getTestSupport(); + + abstract TestConfiguration getConfiguration(); + + abstract int getExpectedHttpStatus(); + + abstract Status getExpectedGrpcStatus(); + + private Server testServer; + private ManagedChannel channel; + + @Nested + class Http { + + private final DropwizardAppExtension DROPWIZARD_APP_EXTENSION = + new DropwizardAppExtension<>(getTestSupport()); + + @Test + void testRestricted() { + Client client = DROPWIZARD_APP_EXTENSION.client(); + + try (Response response = client.target( + "http://localhost:%s/test/restricted".formatted(DROPWIZARD_APP_EXTENSION.getLocalPort())) + .request() + .get()) { + + assertEquals(getExpectedHttpStatus(), response.getStatus()); + } + } + + @Test + void testOpen() { + + Client client = DROPWIZARD_APP_EXTENSION.client(); + + try (Response response = client.target( + "http://localhost:%s/test/open".formatted(DROPWIZARD_APP_EXTENSION.getLocalPort())) + .request() + .get()) { + + assertEquals(200, response.getStatus()); + } + + } + } + + @Nested + class Grpc { + + @BeforeEach + void setUp() throws Exception { + final MockRequestAttributesInterceptor mockRequestAttributesInterceptor = new MockRequestAttributesInterceptor(); + mockRequestAttributesInterceptor.setRemoteAddress(InetAddress.getByName("127.0.0.1")); + + testServer = InProcessServerBuilder.forName("ExternalRequestFilterTest") + .directExecutor() + .addService(new EchoServiceImpl()) + .intercept(new ExternalRequestFilter(getConfiguration().getPermittedRanges(), + Set.of("org.signal.chat.rpc.EchoService/echo2"))) + .intercept(mockRequestAttributesInterceptor) + .build() + .start(); + + channel = InProcessChannelBuilder.forName("ExternalRequestFilterTest") + .directExecutor() + .build(); + } + + @Test + void testBlocked() { + final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc.newBlockingStub(channel); + + final String text = "0123456789"; + final EchoRequest req = EchoRequest.newBuilder().setPayload(ByteString.copyFromUtf8(text)).build(); + + final Status expectedGrpcStatus = getExpectedGrpcStatus(); + if (Status.Code.OK == expectedGrpcStatus.getCode()) { + assertEquals(text, client.echo2(req).getPayload().toStringUtf8()); + } else { + GrpcTestUtils.assertStatusException(expectedGrpcStatus, () -> client.echo2(req)); + } + } + + @Test + void testOpen() { + final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc.newBlockingStub(channel); + + final String text = "0123456789"; + final EchoRequest req = EchoRequest.newBuilder().setPayload(ByteString.copyFromUtf8(text)).build(); + + assertEquals(text, client.echo(req).getPayload().toStringUtf8()); + } + + @AfterEach + void tearDown() throws Exception { + + testServer.shutdownNow() + .awaitTermination(10, TimeUnit.SECONDS); + } + } + + @Path("/test") + public static class Controller { + + @GET + @Path("/restricted") + public Response restricted() { + return Response.ok().build(); + } + + @GET + @Path("/open") + public Response open() { + return Response.ok().build(); + } + } + + public static class TestApplication extends Application { + + @Override + public void run(final TestConfiguration configuration, final Environment environment) throws Exception { + + environment.jersey().register(new Controller()); + environment.servlets() + .addFilter("ExternalRequestFilter", + new ExternalRequestFilter(configuration.getPermittedRanges(), + Collections.emptySet())) + .addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/test/restricted"); + } + } + + public abstract static class TestConfiguration extends Configuration { + + public abstract Set getPermittedRanges(); + } + } + +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/EchoServiceImpl.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/EchoServiceImpl.java index d1dd1974d..81714ecb1 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/EchoServiceImpl.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/EchoServiceImpl.java @@ -6,9 +6,9 @@ package org.whispersystems.textsecuregcm.grpc; import io.grpc.stub.StreamObserver; -import org.signal.chat.rpc.EchoServiceGrpc; import org.signal.chat.rpc.EchoRequest; import org.signal.chat.rpc.EchoResponse; +import org.signal.chat.rpc.EchoServiceGrpc; public class EchoServiceImpl extends EchoServiceGrpc.EchoServiceImplBase { @Override @@ -16,4 +16,10 @@ public class EchoServiceImpl extends EchoServiceGrpc.EchoServiceImplBase { responseObserver.onNext(EchoResponse.newBuilder().setPayload(req.getPayload()).build()); responseObserver.onCompleted(); } + + @Override + public void echo2(EchoRequest req, StreamObserver responseObserver) { + responseObserver.onNext(EchoResponse.newBuilder().setPayload(req.getPayload()).build()); + responseObserver.onCompleted(); + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/InetAddressRangeTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/InetAddressRangeTest.java new file mode 100644 index 000000000..bed4b7f83 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/util/InetAddressRangeTest.java @@ -0,0 +1,78 @@ +/* + * Copyright 2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.util; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +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; + +class InetAddressRangeTest { + + @ParameterizedTest + @ValueSource(strings = {"192.168.0.1", "192.168.0.0/33", "$%#*(@!&^$/24", "192.168.0.0/fish", "signal.org"}) + void testBogusCidrBlock(final String cidrBlock) { + assertThrows(IllegalArgumentException.class, () -> new InetAddressRange(cidrBlock)); + } + + @ParameterizedTest + @MethodSource("argumentsForTestGeneratePrefixMask") + void testGeneratePrefixMask(final int addressLengthBytes, final int prefixLengthBits, final byte[] expectedMask) { + assertArrayEquals(expectedMask, InetAddressRange.generatePrefixMask(addressLengthBytes, prefixLengthBits)); + } + + private static Stream argumentsForTestGeneratePrefixMask() { + return Stream.of( + Arguments.of(4, 32, new byte[]{(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff}), + Arguments.of(4, 24, new byte[]{(byte) 0xff, (byte) 0xff, (byte) 0xff, 0x00}), + Arguments.of(4, 22, new byte[]{(byte) 0xff, (byte) 0xff, (byte) 0xfc, 0x00}), + Arguments.of(4, 0, new byte[]{0x00, 0x00, 0x00, 0x00}) + ); + } + + @ParameterizedTest + @MethodSource("argumentsForTestContains") + void testContains(final String cidrBlock, final String address, final boolean expectContains) { + assertEquals(expectContains, new InetAddressRange(cidrBlock).contains(address)); + } + + private static Stream argumentsForTestContains() { + return Stream.of( + Arguments.of("192.168.0.0/24", "192.168.0.1", true), + Arguments.of("192.168.0.0/24", "192.168.1.0", false), + Arguments.of("192.168.0.1/32", "192.168.0.1", true), + Arguments.of("192.168.0.1/32", "192.168.0.0", false), + Arguments.of("2001:db8::/48", "2001:db8:0:0:0:0:0:0", true), + Arguments.of("2001:db8::/48", "2001:db8:0:ffff:ffff:ffff:ffff:ffff", true), + Arguments.of("2001:db8::/48", "2001:db6:0:ffff:ffff:ffff:ffff:ffff", false) + ); + } + + @Test + void testContainsMismatchedAddressType() { + assertFalse(new InetAddressRange("192.168.0.0/24").contains("2001:db8:0:0:0:0:0:0")); + assertFalse(new InetAddressRange("2001:db8::/48").contains("192.168.0.1")); + } + + @Test + void testDeserialize() throws JsonProcessingException { + final TypeReference> typeReference = new TypeReference<>() {}; + + assertEquals(Map.of("range", new InetAddressRange("192.168.0.0/24")), + new ObjectMapper().readValue("{\"range\":\"192.168.0.0/24\"}", typeReference)); + } +} diff --git a/service/src/test/proto/echo_service.proto b/service/src/test/proto/echo_service.proto index 9cc4a6ff5..d411b6a72 100644 --- a/service/src/test/proto/echo_service.proto +++ b/service/src/test/proto/echo_service.proto @@ -12,6 +12,7 @@ package org.signal.chat.rpc; // A simple service for testing gRPC interceptors service EchoService { rpc echo (EchoRequest) returns (EchoResponse) {} + rpc echo2 (EchoRequest) returns (EchoResponse) {} } message EchoRequest {