This is an automated email from the ASF dual-hosted git repository.
raboof pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-grpc.git
The following commit(s) were added to refs/heads/main by this push:
new 6477f539 apply a max message len (#818)
6477f539 is described below
commit 6477f539792c1a96fe799fc9b3e7abd9990d3a55
Author: PJ Fanning <[email protected]>
AuthorDate: Sun Aug 30 08:38:53 2026 +0100
apply a max message len (#818)
* apply a max message len
Motivation:
gRPC message size limits protect both clients and servers from decompression
bombs and memory exhaustion. grpc-java defaults to 4 MiB but Pekko gRPC had
no
limit, leaving it vulnerable to oversized frames and gzip decompression
attacks.
Modification:
Protocol layer - `GrpcFramingDecoderStage` and the strict decoder validate
the
declared frame length against `maxInboundMessageSize` before the payload is
buffered. `Codec.uncompress` gained overloads that bound the decompressed
size
and throw `RESOURCE_EXHAUSTED` when it is exceeded; the compression-bit
overload
delegates to the size-bounded one so `Gzip` can fail fast while
decompressing
rather than after. `Gzip` checks cumulative output during streaming
decompression using a `Long` accumulator and a clamped initial buffer.
Negative
frame lengths are reported as `INTERNAL` on both the streaming and the
strict
path.
Client side - added `max-inbound-message-size` to `pekko.grpc.client."*"`
(default 4 MiB) and `GrpcClientSettings.withMaxInboundMessageSize`. Both
backends honour it.
Server side - added `pekko.grpc.server.max-inbound-message-size` and a new
`GrpcServerSettings` class. Generated Scala and Java handlers resolve the
setting once per `partial`/`handler` call rather than per request.
Result:
Both clients and servers reject inbound messages exceeding the configured
limit
with `RESOURCE_EXHAUSTED`. Oversized frames are rejected from the declared
length before the payload is buffered, and gzip frames that inflate past the
limit are rejected while decompressing rather than after.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* Deprecate the unbounded Codec.uncompress overloads
Motivation:
`uncompress(bytes)` and `uncompress(compressedBitSet, bytes)` place no
bound on
the decompressed output, so a caller that reaches for them is exposed to the
decompression bomb this branch otherwise closes. No production call site
uses
them any more - the framing decoder went through the size-bounded overloads
-
but they remain public and are the shape through which a future caller would
reintroduce the problem.
Modification:
Marked both overloads `@deprecated` in `Codec`, `Gzip` and `Identity`,
pointing
at the size-bounded replacements. The default `uncompress(bytes, max)` in
`Codec` and `Gzip`'s compression-bit overload still delegate to them, so
both
carry `@nowarn("cat=deprecation")`; the build runs with `-Xfatal-warnings`.
Documented on the default `uncompress(bytes, max)` that it checks after
decompressing and that codecs able to fail fast should override it, as
`Gzip`
does.
Result:
The unbounded entry points still work for source compatibility but now warn,
and the bounded overloads are the discoverable ones.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../twirl/templates/JavaServer/Handler.scala.txt | 21 ++-
.../twirl/templates/ScalaServer/Handler.scala.txt | 14 +-
docs/src/main/paradox/client/configuration.md | 29 +++
docs/src/main/paradox/server/details.md | 57 ++++++
.../max-inbound-message-size.excludes | 31 ++++
runtime/src/main/resources/reference.conf | 18 ++
.../org/apache/pekko/grpc/GrpcClientSettings.scala | 26 ++-
.../scala/org/apache/pekko/grpc/GrpcProtocol.scala | 28 ++-
.../org/apache/pekko/grpc/GrpcServerSettings.scala | 72 +++++++
.../pekko/grpc/internal/AbstractGrpcProtocol.scala | 66 +++++--
.../org/apache/pekko/grpc/internal/Codec.scala | 60 ++++++
.../pekko/grpc/internal/GrpcProtocolNative.scala | 4 +-
.../pekko/grpc/internal/GrpcProtocolWeb.scala | 4 +-
.../org/apache/pekko/grpc/internal/Gzip.scala | 39 ++++
.../org/apache/pekko/grpc/internal/Identity.scala | 21 +++
.../pekko/grpc/internal/NettyClientUtils.scala | 2 +
.../pekko/grpc/internal/PekkoHttpClientUtils.scala | 12 +-
.../pekko/grpc/javadsl/GrpcMarshalling.scala | 21 +++
.../pekko/grpc/scaladsl/GrpcMarshalling.scala | 18 ++
.../apache/pekko/grpc/GrpcClientSettingsSpec.scala | 43 +++++
.../apache/pekko/grpc/GrpcServerSettingsSpec.scala | 64 +++++++
.../grpc/internal/MaxInboundMessageSizeSpec.scala | 206 +++++++++++++++++++++
22 files changed, 825 insertions(+), 31 deletions(-)
diff --git a/codegen/src/main/twirl/templates/JavaServer/Handler.scala.txt
b/codegen/src/main/twirl/templates/JavaServer/Handler.scala.txt
index d8796868..18194324 100644
--- a/codegen/src/main/twirl/templates/JavaServer/Handler.scala.txt
+++ b/codegen/src/main/twirl/templates/JavaServer/Handler.scala.txt
@@ -27,6 +27,7 @@ import org.apache.pekko.annotation.ApiMayChange;
import org.apache.pekko.stream.Materializer;
import org.apache.pekko.stream.SystemMaterializer;
+import org.apache.pekko.grpc.GrpcServerSettings;
import org.apache.pekko.grpc.Trailers;
import org.apache.pekko.grpc.javadsl.GrpcMarshalling;
import org.apache.pekko.grpc.javadsl.GrpcExceptionHandler;
@@ -143,14 +144,28 @@ public class @{serviceName}HandlerFactory {
* Use {@@link
org.apache.pekko.grpc.javadsl.ServiceHandler#concatOrNotFound} when combining
several services.
*/
public static Function<org.apache.pekko.http.javadsl.model.HttpRequest,
CompletionStage<org.apache.pekko.http.javadsl.model.HttpResponse>>
partial(@serviceName implementation, String prefix, Materializer mat,
org.apache.pekko.japi.function.Function<ActorSystem,
org.apache.pekko.japi.function.Function<Throwable, Trailers>> eHandler,
ClassicActorSystemProvider system) {
+ return partial(implementation, prefix, mat, eHandler,
GrpcServerSettings.create(system), system);
+ }
+
+ /**
+ * Creates a `HttpRequest` to `HttpResponse` handler that can be used in
for example
+ * `Http.get(system).bindAndHandleAsync`. It ends with
`StatusCodes.NotFound` if the request is not matching.
+ *
+ * Use {@@link
org.apache.pekko.grpc.javadsl.ServiceHandler#concatOrNotFound} when combining
several services.
+ *
+ * @@param settings server settings, including the maximum allowed inbound
message size
+ */
+ public static Function<org.apache.pekko.http.javadsl.model.HttpRequest,
CompletionStage<org.apache.pekko.http.javadsl.model.HttpResponse>>
partial(@serviceName implementation, String prefix, Materializer mat,
org.apache.pekko.japi.function.Function<ActorSystem,
org.apache.pekko.japi.function.Function<Throwable, Trailers>> eHandler,
GrpcServerSettings settings, ClassicActorSystemProvider system) {
TelemetrySpi spi = TelemetryExtension.get(system).spi();
+ // resolved once, outside the returned function, so config is not
re-read per request
+ int maxInboundMessageSize = settings.maxInboundMessageSize();
return (req -> {
Iterator<String> segments = req.getUri().pathSegments().iterator();
if (segments.hasNext() && segments.next().equals(prefix) &&
segments.hasNext()) {
String method = segments.next();
if (segments.hasNext()) return notFound; // we don't allow any
random `/prefix/Method/anything/here
else {
- return handle(spi.onRequest(prefix, method, req), method,
implementation, mat, eHandler, system);
+ return handle(spi.onRequest(prefix, method, req), method,
implementation, mat, eHandler, maxInboundMessageSize, system);
}
} else {
return notFound;
@@ -162,8 +177,8 @@ public class @{serviceName}HandlerFactory {
return @{service.name}.name;
}
- private static
CompletionStage<org.apache.pekko.http.javadsl.model.HttpResponse>
handle(org.apache.pekko.http.javadsl.model.HttpRequest request, String method,
@serviceName implementation, Materializer mat,
org.apache.pekko.japi.function.Function<ActorSystem,
org.apache.pekko.japi.function.Function<Throwable, Trailers>> eHandler,
ClassicActorSystemProvider system) {
- return GrpcMarshalling.negotiated(request, (reader, writer) -> {
+ private static
CompletionStage<org.apache.pekko.http.javadsl.model.HttpResponse>
handle(org.apache.pekko.http.javadsl.model.HttpRequest request, String method,
@serviceName implementation, Materializer mat,
org.apache.pekko.japi.function.Function<ActorSystem,
org.apache.pekko.japi.function.Function<Throwable, Trailers>> eHandler, int
maxInboundMessageSize, ClassicActorSystemProvider system) {
+ return GrpcMarshalling.negotiatedWithMaxSize(request,
maxInboundMessageSize, (reader, writer) -> {
CompletionStage<org.apache.pekko.http.javadsl.model.HttpResponse>
response;
@{if(powerApis) { "Metadata metadata =
MetadataBuilder.fromHeaders(request.getHeaders());" } else { "" }}
switch(method) {
diff --git a/codegen/src/main/twirl/templates/ScalaServer/Handler.scala.txt
b/codegen/src/main/twirl/templates/ScalaServer/Handler.scala.txt
index d7c2c183..130c663a 100644
--- a/codegen/src/main/twirl/templates/ScalaServer/Handler.scala.txt
+++ b/codegen/src/main/twirl/templates/ScalaServer/Handler.scala.txt
@@ -20,7 +20,7 @@ import scala.concurrent.ExecutionContext
import org.apache.pekko
import pekko.grpc.scaladsl.{ GrpcExceptionHandler, GrpcMarshalling }
-import pekko.grpc.Trailers
+import pekko.grpc.{ GrpcServerSettings, Trailers }
import pekko.actor.ActorSystem
import pekko.actor.ClassicActorSystemProvider
@@ -115,7 +115,9 @@ object @{serviceName}Handler {
null
}
- private def handler(implementation: @serviceName, prefix: String,
eHandler: ActorSystem => PartialFunction[Throwable,
Trailers])(@{service.scalaCompatConstants.ImplicitParameter} system:
ClassicActorSystemProvider): model.HttpRequest =>
scala.concurrent.Future[model.HttpResponse] = {
+ private def handler(implementation: @serviceName, prefix: String,
eHandler: ActorSystem => PartialFunction[Throwable, Trailers], settings:
Option[GrpcServerSettings] =
None)(@{service.scalaCompatConstants.ImplicitParameter} system:
ClassicActorSystemProvider): model.HttpRequest =>
scala.concurrent.Future[model.HttpResponse] = {
+ // resolved once, outside the returned function, so config is not
re-read per request
+ val maxInboundMessageSize =
settings.getOrElse(GrpcServerSettings.create(system)).maxInboundMessageSize
@{service.scalaCompatConstants.ImplicitVal} mat: Materializer =
SystemMaterializer(system).materializer
@{service.scalaCompatConstants.ImplicitVal} ec: ExecutionContext =
mat.executionContext
val spi = TelemetryExtension(system).spi
@@ -123,7 +125,7 @@ object @{serviceName}Handler {
import
@{service.name}.Serializers.@{service.scalaCompatConstants.WildcardImport}
def handle(request: model.HttpRequest, method: String):
scala.concurrent.Future[model.HttpResponse] =
- GrpcMarshalling.negotiated(request, (reader, writer) =>
+ GrpcMarshalling.negotiatedWithMaxSize(request, maxInboundMessageSize,
(reader, writer) =>
method match {
@for(method <- service.methods) {
case "@method.grpcName" =>
@@ -158,7 +160,9 @@ object @{serviceName}Handler {
*
* Registering a gRPC service under a custom prefix is not widely
supported and strongly discouraged by the specification.
*/
- def partial(implementation: @serviceName, prefix: String =
@{service.name}.name, eHandler: ActorSystem => PartialFunction[Throwable,
Trailers] =
GrpcExceptionHandler.defaultMapper)(@{service.scalaCompatConstants.ImplicitParameter}
system: ClassicActorSystemProvider): PartialFunction[model.HttpRequest,
scala.concurrent.Future[model.HttpResponse]] = {
+ def partial(implementation: @serviceName, prefix: String =
@{service.name}.name, eHandler: ActorSystem => PartialFunction[Throwable,
Trailers] = GrpcExceptionHandler.defaultMapper, settings:
Option[GrpcServerSettings] =
None)(@{service.scalaCompatConstants.ImplicitParameter} system:
ClassicActorSystemProvider): PartialFunction[model.HttpRequest,
scala.concurrent.Future[model.HttpResponse]] = {
+ // resolved once, outside the returned partial function, so config is
not re-read per request
+ val maxInboundMessageSize =
settings.getOrElse(GrpcServerSettings.create(system)).maxInboundMessageSize
@{service.scalaCompatConstants.ImplicitVal} mat: Materializer =
SystemMaterializer(system).materializer
@{service.scalaCompatConstants.ImplicitVal} ec: ExecutionContext =
mat.executionContext
val spi = TelemetryExtension(system).spi
@@ -166,7 +170,7 @@ object @{serviceName}Handler {
import
@{service.name}.Serializers.@{service.scalaCompatConstants.WildcardImport}
def handle(request: model.HttpRequest, method: String):
scala.concurrent.Future[model.HttpResponse] =
- GrpcMarshalling.negotiated(request, (reader, writer) =>
+ GrpcMarshalling.negotiatedWithMaxSize(request, maxInboundMessageSize,
(reader, writer) =>
method match {
@for(method <- service.methods) {
case "@method.grpcName" =>
diff --git a/docs/src/main/paradox/client/configuration.md
b/docs/src/main/paradox/client/configuration.md
index bbe48862..6a0d7f75 100644
--- a/docs/src/main/paradox/client/configuration.md
+++ b/docs/src/main/paradox/client/configuration.md
@@ -82,6 +82,35 @@ effect and is logged as a warning.
@@@
+## Maximum inbound message size
+
+Inbound gRPC messages are limited to 4 MiB by default, matching the grpc-java
default; larger messages
+are rejected with `RESOURCE_EXHAUSTED`. The limit applies to both the `netty`
and the `pekko-http`
+backend, and covers the decompressed size, so a compressed response that
inflates past the limit is
+rejected while it is being decompressed.
+
+Set it in configuration:
+
+```hocon
+pekko.grpc.client."*" {
+ max-inbound-message-size = 8388608 # 8 MiB
+}
+```
+
+or programmatically:
+
+Scala
+: ```scala
+ val settings = GrpcClientSettings.connectToServiceAt("localhost", 8080)
+ .withMaxInboundMessageSize(8 * 1024 * 1024)
+ ```
+
+Java
+: ```java
+ GrpcClientSettings settings =
GrpcClientSettings.connectToServiceAt("localhost", 8080, system)
+ .withMaxInboundMessageSize(8 * 1024 * 1024);
+ ```
+
## Using Pekko Discovery for Endpoint Discovery
The examples above all use a hard coded host and port for the location of the
gRPC service which is the default if you do not configure a
`service-discovery-mechanism`.
diff --git a/docs/src/main/paradox/server/details.md
b/docs/src/main/paradox/server/details.md
index 839a185a..85cbee9b 100644
--- a/docs/src/main/paradox/server/details.md
+++ b/docs/src/main/paradox/server/details.md
@@ -55,3 +55,60 @@ Java
:
@@snip[RichErrorModelTest](/interop-tests/src/test/java/example/myapp/helloworld/grpc/RichErrorNativeImpl.java)
{ #rich_error_model_unary }
Please look @ref[here](../client/details.md) how to handle this on the client.
+
+## Maximum inbound message size
+
+Inbound gRPC messages are limited to 4 MiB by default, matching the grpc-java
default. A frame whose
+declared length exceeds the limit is rejected before its payload is read, and
a compressed frame that
+inflates past the limit is rejected while it is being decompressed, so neither
an oversized frame nor a
+decompression bomb needs to be buffered in full. In both cases the peer sees a
`RESOURCE_EXHAUSTED` status.
+
+The limit is read from `pekko.grpc.server`:
+
+`reference.conf`
+: @@snip [reference](/runtime/src/main/resources/reference.conf) {
#server-defaults }
+
+To raise it for every service in the actor system, override the setting in
your `application.conf`:
+
+```hocon
+pekko.grpc.server {
+ max-inbound-message-size = 8388608 # 8 MiB
+}
+```
+
+To use a different limit for a single service, pass
@apidoc[GrpcServerSettings] to the generated
+handler's `partial` method:
+
+Scala
+: ```scala
+ val settings = GrpcServerSettings(system).withMaxInboundMessageSize(8 *
1024 * 1024)
+ val handler = GreeterServiceHandler.partial(
+ new GreeterServiceImpl(),
+ settings = Some(settings))
+ ```
+
+Java
+: ```java
+ GrpcServerSettings settings =
+ GrpcServerSettings.create(system).withMaxInboundMessageSize(8 * 1024 *
1024);
+ Function<HttpRequest, CompletionStage<HttpResponse>> handler =
+ GreeterServiceHandlerFactory.partial(
+ new GreeterServiceImpl(),
+ GreeterService.name,
+ SystemMaterializer.get(system).materializer(),
+ GrpcExceptionHandler.defaultMapper(),
+ settings,
+ system);
+ ```
+
+@@@ note
+
+The limit did not exist before 2.0.0, so a server that previously accepted
messages larger than 4 MiB
+will start rejecting them after upgrading. Raise
`pekko.grpc.server.max-inbound-message-size` to keep
+the old behaviour.
+
+Handlers **generated before 2.0.0** call an entry point that has no access to
the actor system's
+configuration, so they fall back on the 4 MiB default and
`pekko.grpc.server.max-inbound-message-size`
+has no effect on them. Regenerate your sources against 2.0.0 to make the
setting apply.
+
+@@@
diff --git
a/runtime/src/main/mima-filters/2.0.x.backwards.excludes/max-inbound-message-size.excludes
b/runtime/src/main/mima-filters/2.0.x.backwards.excludes/max-inbound-message-size.excludes
new file mode 100644
index 00000000..689859db
--- /dev/null
+++
b/runtime/src/main/mima-filters/2.0.x.backwards.excludes/max-inbound-message-size.excludes
@@ -0,0 +1,31 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# add max-inbound-message-size
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.grpc.GrpcProtocol.newReader")
+ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.grpc.GrpcProtocol.newReader")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.grpc.internal.AbstractGrpcProtocol.newReader")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.grpc.internal.AbstractGrpcProtocol.reader")
+ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.grpc.internal.AbstractGrpcProtocol.reader")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.grpc.internal.AbstractGrpcProtocol.reader")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.grpc.internal.AbstractGrpcProtocol#GrpcFramingDecoderStage.this")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.grpc.internal.GrpcProtocolNative.newReader")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.grpc.internal.GrpcProtocolNative.reader")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.grpc.internal.GrpcProtocolWeb.newReader")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.grpc.internal.GrpcProtocolWebBase.reader")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.grpc.internal.GrpcProtocolWebText.newReader")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.grpc.internal.PekkoHttpClientUtils.responseToSource")
diff --git a/runtime/src/main/resources/reference.conf
b/runtime/src/main/resources/reference.conf
index 8aaecc39..2f8c1a36 100644
--- a/runtime/src/main/resources/reference.conf
+++ b/runtime/src/main/resources/reference.conf
@@ -46,6 +46,13 @@ pekko.grpc.client."*" {
# TODO: Enforce HTTP/2 TLS restrictions:
https://tools.ietf.org/html/draft-ietf-httpbis-http2-17#section-9.2
+ # Maximum allowed size for inbound gRPC messages (in bytes).
+ # Applies to the decompressed message size. Messages exceeding this limit
+ # will be rejected with RESOURCE_EXHAUSTED status.
+ # Applies to both the netty and the pekko-http backend.
+ # Default is 4 MiB, matching grpc-java's default.
+ max-inbound-message-size = 4194304
+
# The number of times to try connecting before giving up.
# '-1': means retry indefinitely, '0' is invalid, '1' means fail
# after the first failed attempt.
@@ -59,4 +66,15 @@ pekko.grpc.client."*" {
# Any of the mechanisms described in
https://pekko.apache.org/docs/pekko-management/current/discovery/index.html can
be used
# including Kubernetes, Consul, AWS API
}
+
//#defaults
+
+//#server-defaults
+pekko.grpc.server {
+ # Maximum allowed size for inbound gRPC messages (in bytes).
+ # Applies to the decompressed message size. Messages exceeding this limit
+ # will be rejected with RESOURCE_EXHAUSTED status.
+ # Default is 4 MiB, matching grpc-java's default.
+ max-inbound-message-size = 4194304
+}
+//#server-defaults
diff --git
a/runtime/src/main/scala/org/apache/pekko/grpc/GrpcClientSettings.scala
b/runtime/src/main/scala/org/apache/pekko/grpc/GrpcClientSettings.scala
index 24bd84c3..2aaef4c1 100644
--- a/runtime/src/main/scala/org/apache/pekko/grpc/GrpcClientSettings.scala
+++ b/runtime/src/main/scala/org/apache/pekko/grpc/GrpcClientSettings.scala
@@ -18,7 +18,7 @@ import pekko.actor.ClassicActorSystemProvider
import pekko.annotation.{ ApiMayChange, InternalApi }
import pekko.discovery.{ Discovery, ServiceDiscovery }
import pekko.discovery.ServiceDiscovery.{ Resolved, ResolvedTarget }
-import pekko.grpc.internal.HardcodedServiceDiscovery
+import pekko.grpc.internal.{ AbstractGrpcProtocol, HardcodedServiceDiscovery }
import pekko.util.Helpers
import com.typesafe.config.{ Config, ConfigValueFactory }
import io.grpc.CallCredentials
@@ -159,8 +159,17 @@ object GrpcClientSettings {
clientConfiguration.getBoolean("use-tls"),
getOptionalString(clientConfiguration, "load-balancing-policy"),
clientConfiguration.getString("backend"),
+ maxInboundMessageSize = getIntWithDefault(
+ clientConfiguration,
+ "max-inbound-message-size",
+ AbstractGrpcProtocol.DefaultMaxInboundMessageSize),
verifyHostname = clientConfiguration.getBoolean("verify-hostname"))
+ // `fromConfig(Config)` is public API and is called with hand-assembled
Configs that do not
+ // necessarily fall back on `pekko.grpc.client."*"`, so a missing key must
not fail.
+ private def getIntWithDefault(config: Config, path: String, default: Int):
Int =
+ if (config.hasPath(path)) config.getInt(path) else default
+
private def getOptionalString(config: Config, path: String): Option[String] =
config.getString(path) match {
case "" => None
@@ -208,6 +217,7 @@ final class GrpcClientSettings private (
val loadBalancingPolicy: Option[String],
val backend: String,
val channelBuilderOverrides: NettyChannelBuilder => NettyChannelBuilder =
identity,
+ val maxInboundMessageSize: Int,
val verifyHostname: Boolean) {
require(
sslContext.isEmpty || trustManager.isEmpty,
@@ -216,6 +226,9 @@ final class GrpcClientSettings private (
if (sslContext.isDefined) sslProvider.forall(_ == SslProvider.JDK) else
true,
"When sslContext is configured, sslProvider must not set to something
different than JDK")
require(backend == "netty" || backend == "pekko-http", "backend should be
'netty' or 'pekko-http'")
+ require(
+ maxInboundMessageSize > 0,
+ s"maxInboundMessageSize must be positive, was [$maxInboundMessageSize]")
/**
* If using ServiceDiscovery and no port is returned use this one.
@@ -291,6 +304,15 @@ final class GrpcClientSettings private (
def withBackend(value: String): GrpcClientSettings =
copy(backend = value)
+ /**
+ * Maximum allowed size for inbound gRPC messages (in bytes).
+ * Applies to the decompressed message size. Messages exceeding this limit
+ * will be rejected with RESOURCE_EXHAUSTED status.
+ * @since 2.0.0
+ */
+ def withMaxInboundMessageSize(value: Int): GrpcClientSettings =
+ copy(maxInboundMessageSize = value)
+
/**
* Whether to verify the server's hostname against its TLS certificate (RFC
2818).
* When false, the client accepts any valid certificate regardless of
hostname.
@@ -319,6 +341,7 @@ final class GrpcClientSettings private (
loadBalancingPolicy: Option[String] = loadBalancingPolicy,
backend: String = backend,
channelBuilderOverrides: NettyChannelBuilder => NettyChannelBuilder =
channelBuilderOverrides,
+ maxInboundMessageSize: Int = maxInboundMessageSize,
verifyHostname: Boolean = verifyHostname)
: GrpcClientSettings =
new GrpcClientSettings(
@@ -340,5 +363,6 @@ final class GrpcClientSettings private (
loadBalancingPolicy = loadBalancingPolicy,
backend = backend,
channelBuilderOverrides = channelBuilderOverrides,
+ maxInboundMessageSize = maxInboundMessageSize,
verifyHostname = verifyHostname)
}
diff --git a/runtime/src/main/scala/org/apache/pekko/grpc/GrpcProtocol.scala
b/runtime/src/main/scala/org/apache/pekko/grpc/GrpcProtocol.scala
index 1d8e2f04..948f2779 100644
--- a/runtime/src/main/scala/org/apache/pekko/grpc/GrpcProtocol.scala
+++ b/runtime/src/main/scala/org/apache/pekko/grpc/GrpcProtocol.scala
@@ -18,7 +18,14 @@ import pekko.NotUsed
import pekko.annotation.InternalApi
import pekko.annotation.InternalStableApi
import pekko.grpc.GrpcProtocol.{ GrpcProtocolReader, GrpcProtocolWriter }
-import pekko.grpc.internal.{ Codec, Codecs, GrpcProtocolNative,
GrpcProtocolWeb, GrpcProtocolWebText }
+import pekko.grpc.internal.{
+ AbstractGrpcProtocol,
+ Codec,
+ Codecs,
+ GrpcProtocolNative,
+ GrpcProtocolWeb,
+ GrpcProtocolWebText
+}
import pekko.http.javadsl.{ model => jmodel }
import pekko.http.scaladsl.model.{ ContentType, HttpHeader, HttpResponse,
Trailer }
import pekko.http.scaladsl.model.HttpEntity.ChunkStreamPart
@@ -63,9 +70,11 @@ trait GrpcProtocol {
*
* Constructs a protocol reader for reading gRPC protocol frames for this
variant.
* @param codec the compression codec to decode data frame bodies with.
+ * @param maxInboundMessageSize the maximum allowed inbound message size in
bytes.
*/
@InternalStableApi
- def newReader(codec: Codec): GrpcProtocolReader
+ def newReader(codec: Codec, maxInboundMessageSize: Int =
AbstractGrpcProtocol.DefaultMaxInboundMessageSize)
+ : GrpcProtocolReader
}
/**
@@ -148,8 +157,21 @@ object GrpcProtocol {
* @return the protocol reader for the request, and a protocol writer for
the response.
*/
def negotiate(request: jmodel.HttpRequest): Option[(Try[GrpcProtocolReader],
GrpcProtocolWriter)] =
+ negotiate(request, AbstractGrpcProtocol.DefaultMaxInboundMessageSize)
+
+ /**
+ * Calculates the gRPC protocol encoding to use for an interaction with a
gRPC client.
+ *
+ * @param request the client request to respond to.
+ * @param maxInboundMessageSize the maximum allowed inbound message size in
bytes.
+ * @return the protocol reader for the request, and a protocol writer for
the response.
+ */
+ def negotiate(
+ request: jmodel.HttpRequest,
+ maxInboundMessageSize: Int): Option[(Try[GrpcProtocolReader],
GrpcProtocolWriter)] =
detect(request).map { variant =>
- (Codecs.detect(request).map(variant.newReader),
variant.newWriter(Codecs.negotiate(request)))
+ (Codecs.detect(request).map(variant.newReader(_, maxInboundMessageSize)),
+ variant.newWriter(Codecs.negotiate(request)))
}
}
diff --git
a/runtime/src/main/scala/org/apache/pekko/grpc/GrpcServerSettings.scala
b/runtime/src/main/scala/org/apache/pekko/grpc/GrpcServerSettings.scala
new file mode 100644
index 00000000..d742745c
--- /dev/null
+++ b/runtime/src/main/scala/org/apache/pekko/grpc/GrpcServerSettings.scala
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+package org.apache.pekko.grpc
+
+import org.apache.pekko
+import pekko.actor.ClassicActorSystemProvider
+import pekko.annotation.ApiMayChange
+import pekko.grpc.internal.AbstractGrpcProtocol
+import com.typesafe.config.Config
+
+object GrpcServerSettings {
+
+ /**
+ * Scala API: Create settings from the actor system's default configuration
(`pekko.grpc.server`).
+ */
+ def apply(implicit actorSystem: ClassicActorSystemProvider):
GrpcServerSettings =
+
fromConfig(actorSystem.classicSystem.settings.config.getConfig("pekko.grpc.server"))
+
+ /**
+ * Java API: Create settings from the actor system's default configuration
(`pekko.grpc.server`).
+ */
+ def create(actorSystem: ClassicActorSystemProvider): GrpcServerSettings =
+ apply(actorSystem)
+
+ /**
+ * Create settings from a custom Config (must contain the same keys as
`pekko.grpc.server`).
+ *
+ * Keys that are absent fall back on the built-in defaults, so a
hand-assembled `Config` that
+ * does not resolve against `reference.conf` keeps working.
+ */
+ def fromConfig(config: Config): GrpcServerSettings =
+ new GrpcServerSettings(
+ maxInboundMessageSize =
+ if (config.hasPath("max-inbound-message-size"))
config.getInt("max-inbound-message-size")
+ else AbstractGrpcProtocol.DefaultMaxInboundMessageSize)
+}
+
+/**
+ * Settings for gRPC server services.
+ *
+ * Read from `pekko.grpc.server` in the actor system's configuration.
+ *
+ * @since 2.0.0
+ */
+@ApiMayChange
+final class GrpcServerSettings private (
+ val maxInboundMessageSize: Int) {
+
+ require(
+ maxInboundMessageSize > 0,
+ s"maxInboundMessageSize must be positive, was [$maxInboundMessageSize]")
+
+ /**
+ * Maximum allowed size for inbound gRPC messages (in bytes).
+ * Applies to the decompressed message size. Messages exceeding this limit
+ * will be rejected with RESOURCE_EXHAUSTED status.
+ * @since 2.0.0
+ */
+ def withMaxInboundMessageSize(value: Int): GrpcServerSettings =
+ new GrpcServerSettings(
+ maxInboundMessageSize = value)
+
+ override def toString: String =
+ s"GrpcServerSettings(maxInboundMessageSize=$maxInboundMessageSize)"
+}
diff --git
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/AbstractGrpcProtocol.scala
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/AbstractGrpcProtocol.scala
index 3067c894..daace3f6 100644
---
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/AbstractGrpcProtocol.scala
+++
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/AbstractGrpcProtocol.scala
@@ -25,7 +25,7 @@ import pekko.stream.impl.io.ByteStringParser.{ ByteReader,
ParseResult, ParseSte
import pekko.stream.scaladsl.Flow
import pekko.stream.stage.GraphStageLogic
import pekko.util.ByteString
-import io.grpc.StatusException
+import io.grpc.{ Status, StatusException }
import scala.collection.immutable
@@ -38,7 +38,17 @@ abstract class AbstractGrpcProtocol(subType: String) extends
GrpcProtocol {
Set(contentType.mediaType, MediaType.applicationBinary(subType,
MediaType.Compressible))
private lazy val knownWriters = Codecs.supportedCodecs.map(c => c ->
writer(c)).toMap.withDefault(writer)
- private lazy val knownReaders = Codecs.supportedCodecs.map(c => c ->
reader(c)).toMap.withDefault(reader)
+
+ /**
+ * Readers for the default message size limit. `newReader` is called once
per inbound request, so
+ * the common case (no per-service override) is served from this cache
rather than rebuilding the
+ * reader, its framing stage and the surrounding flows every time.
+ */
+ private lazy val knownReaders =
+ Codecs.supportedCodecs
+ .map(c => c -> reader(c,
AbstractGrpcProtocol.DefaultMaxInboundMessageSize))
+ .toMap
+ .withDefault(reader(_,
AbstractGrpcProtocol.DefaultMaxInboundMessageSize))
/**
* Obtains a writer for this protocol:
@@ -50,12 +60,17 @@ abstract class AbstractGrpcProtocol(subType: String)
extends GrpcProtocol {
* Obtains a reader for this protocol.
*
* @param codec the codec to use for compressed frames.
+ * @param maxInboundMessageSize the maximum allowed inbound message size in
bytes.
*/
- override def newReader(codec: Codec): GrpcProtocolReader =
knownReaders(codec)
+ override def newReader(
+ codec: Codec,
+ maxInboundMessageSize: Int =
AbstractGrpcProtocol.DefaultMaxInboundMessageSize): GrpcProtocolReader =
+ if (maxInboundMessageSize ==
AbstractGrpcProtocol.DefaultMaxInboundMessageSize) knownReaders(codec)
+ else reader(codec, maxInboundMessageSize)
protected def writer(codec: Codec): GrpcProtocolWriter
- protected def reader(codec: Codec): GrpcProtocolReader
+ protected def reader(codec: Codec, maxInboundMessageSize: Int):
GrpcProtocolReader
}
object AbstractGrpcProtocol {
@@ -118,11 +133,17 @@ object AbstractGrpcProtocol {
encodeDataToResponse,
Flow[Frame].map(encodeFrame))
+ /**
+ * The default maximum inbound message size (4 MiB), matching grpc-java's
default.
+ */
+ val DefaultMaxInboundMessageSize: Int = 4 * 1024 * 1024
+
def reader(
codec: Codec,
decodeFrame: (Int, ByteString) => Frame,
preDecodeStrict: ByteString => ByteString = null,
- preDecodeFlow: Flow[ByteString, ByteString, NotUsed] = null):
GrpcProtocolReader = {
+ preDecodeFlow: Flow[ByteString, ByteString, NotUsed] = null,
+ maxInboundMessageSize: Int = DefaultMaxInboundMessageSize):
GrpcProtocolReader = {
val strictAdapter: ByteString => ByteString = if (preDecodeStrict eq null)
identity else preDecodeStrict
val adapter: Flow[ByteString, Frame, NotUsed] => Flow[ByteString, Frame,
NotUsed] =
if (preDecodeFlow eq null) identity
@@ -134,30 +155,51 @@ object AbstractGrpcProtocol {
val reader = new ByteReader(strictAdapter(bs))
val frameType = reader.readByte()
val length = reader.readIntBE()
- if (length < 0) throw new IllegalStateException(s"Frame length must
not be negative, was $length")
+ if (length < 0)
+ throw new StatusException(Status.INTERNAL.withDescription(s"Frame
length must not be negative, was $length"))
+ if (length > maxInboundMessageSize)
+ throw new StatusException(
+ Status.RESOURCE_EXHAUSTED.withDescription(
+ s"Frame length $length exceeds maximum inbound message size
$maxInboundMessageSize"))
val data = reader.take(length)
if (reader.hasRemaining) throw new IllegalStateException("Unexpected
data")
- if ((frameType & 0x80) == 0) codec.uncompress((frameType & 1) == 1,
data)
+ if ((frameType & 0x80) == 0) codec.uncompress((frameType & 1) == 1,
data, maxInboundMessageSize)
else throw new IllegalStateException("Cannot read unknown frame")
} catch { case ByteStringParser.NeedMoreData => throw new
MissingParameterException }
- GrpcProtocolReader(codec, decoder, adapter(Flow.fromGraph(new
GrpcFramingDecoderStage(codec, decodeFrame))))
+ GrpcProtocolReader(
+ codec,
+ decoder,
+ adapter(Flow.fromGraph(new GrpcFramingDecoderStage(codec, decodeFrame,
maxInboundMessageSize))))
}
- class GrpcFramingDecoderStage(codec: Codec, deframe: (Int, ByteString) =>
Frame) extends ByteStringParser[Frame] {
+ class GrpcFramingDecoderStage(codec: Codec, deframe: (Int, ByteString) =>
Frame, maxInboundMessageSize: Int)
+ extends ByteStringParser[Frame] {
override def createLogic(inheritedAttributes: Attributes): GraphStageLogic
=
new ParsingLogic {
startWith(ReadFrameHeader)
trait Step extends ParseStep[Frame]
+ // handle explicitly to avoid noisy log: a peer sending oversized or
malformed frame
+ // headers should not cost us a stack trace per attempt
+ private def failWith(status: Status): ParseResult[Frame] = {
+ failStage(new StatusException(status))
+ ParseResult(None, Failed)
+ }
+
object ReadFrameHeader extends Step {
override def parse(reader: ByteReader): ParseResult[Frame] = {
val frameType = reader.readByte()
val length = reader.readIntBE()
- if (length < 0) throw new IllegalStateException(s"Frame length
must not be negative, was $length")
- if (length == 0) ParseResult(Some(deframe(frameType,
ByteString.empty)), ReadFrameHeader)
+ if (length < 0)
+ failWith(Status.INTERNAL.withDescription(s"Frame length must not
be negative, was $length"))
+ else if (length > maxInboundMessageSize)
+ failWith(
+ Status.RESOURCE_EXHAUSTED.withDescription(
+ s"Frame length $length exceeds maximum inbound message size
$maxInboundMessageSize"))
+ else if (length == 0) ParseResult(Some(deframe(frameType,
ByteString.empty)), ReadFrameHeader)
else ParseResult(None, ReadFrame(frameType, length),
acceptUpstreamFinish = false)
}
}
@@ -167,7 +209,7 @@ object AbstractGrpcProtocol {
override def parse(reader: ByteReader): ParseResult[Frame] =
try ParseResult(
- Some(deframe(frameType, codec.uncompress(compression,
reader.take(length)))),
+ Some(deframe(frameType, codec.uncompress(compression,
reader.take(length), maxInboundMessageSize))),
ReadFrameHeader)
catch {
case s: StatusException =>
diff --git a/runtime/src/main/scala/org/apache/pekko/grpc/internal/Codec.scala
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/Codec.scala
index b01d2403..90c88f59 100644
--- a/runtime/src/main/scala/org/apache/pekko/grpc/internal/Codec.scala
+++ b/runtime/src/main/scala/org/apache/pekko/grpc/internal/Codec.scala
@@ -14,18 +14,78 @@
package org.apache.pekko.grpc.internal
import org.apache.pekko.util.ByteString
+import io.grpc.{ Status, StatusException }
+
+import scala.annotation.nowarn
abstract class Codec {
val name: String
def compress(bytes: ByteString): ByteString
+
+ /**
+ * Decompress the given bytes with no bound on the output size.
+ *
+ * A compressed frame can inflate to arbitrarily many bytes, so a caller
that does not
+ * impose a limit is exposed to a decompression bomb. Prefer
+ * `uncompress(bytes, maxDecompressedSize)`, which fails with
`RESOURCE_EXHAUSTED`
+ * instead of allocating without bound.
+ */
+ @deprecated("Use uncompress(bytes, maxDecompressedSize), which bounds the
decompressed size", "2.0.0")
def uncompress(bytes: ByteString): ByteString
/**
* Process the given frame bytes, uncompress if the compression bit is set.
Identity
* codec will fail with a `io.grpc.StatusException` if the compressedBit is
set.
+ *
+ * Places no bound on the decompressed size; prefer
+ * `uncompress(compressedBitSet, bytes, maxDecompressedSize)`.
*/
+ @deprecated(
+ "Use uncompress(compressedBitSet, bytes, maxDecompressedSize), which
bounds the decompressed size",
+ "2.0.0")
def uncompress(compressedBitSet: Boolean, bytes: ByteString): ByteString
+ /**
+ * Decompress the given bytes, enforcing a maximum decompressed size.
+ * Throws a `StatusException` with `RESOURCE_EXHAUSTED` if the decompressed
+ * output exceeds `maxDecompressedSize`.
+ *
+ * This default implementation decompresses in full and checks afterwards,
so it bounds
+ * what a caller receives but not what is allocated along the way. Codecs
that can enforce
+ * the limit while decompressing should override it; `Gzip` does.
+ *
+ * @param bytes the compressed bytes
+ * @param maxDecompressedSize the maximum allowed decompressed size in bytes
+ */
+ @nowarn("cat=deprecation")
+ def uncompress(bytes: ByteString, maxDecompressedSize: Int): ByteString = {
+ val result = uncompress(bytes)
+ if (result.length > maxDecompressedSize)
+ throw new StatusException(
+ Status.RESOURCE_EXHAUSTED.withDescription(
+ s"Decompressed message size ${result.length} exceeds maximum allowed
$maxDecompressedSize"))
+ result
+ }
+
+ /**
+ * Process the given frame bytes, uncompress if the compression bit is set,
+ * enforcing a maximum decompressed size.
+ *
+ * Delegates to `uncompress(bytes, maxDecompressedSize)` so that codecs
which can enforce
+ * the limit while decompressing (rather than after) get the chance to fail
fast.
+ *
+ * @param compressedBitSet whether the compression bit is set
+ * @param bytes the frame bytes
+ * @param maxDecompressedSize the maximum allowed decompressed size in bytes
+ */
+ def uncompress(compressedBitSet: Boolean, bytes: ByteString,
maxDecompressedSize: Int): ByteString =
+ if (compressedBitSet) uncompress(bytes, maxDecompressedSize)
+ else if (bytes.length > maxDecompressedSize)
+ throw new StatusException(
+ Status.RESOURCE_EXHAUSTED.withDescription(
+ s"Message size ${bytes.length} exceeds maximum allowed
$maxDecompressedSize bytes"))
+ else bytes
+
def isCompressed: Boolean = this != Identity
}
diff --git
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/GrpcProtocolNative.scala
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/GrpcProtocolNative.scala
index 4338f937..e7745703 100644
---
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/GrpcProtocolNative.scala
+++
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/GrpcProtocolNative.scala
@@ -43,8 +43,8 @@ object GrpcProtocolNative extends
AbstractGrpcProtocol("grpc") {
override protected def writer(codec: Codec) =
AbstractGrpcProtocol.writer(this, codec, encodeFrame(codec, _),
encodeDataToResponse(codec))
- override protected def reader(codec: Codec): GrpcProtocolReader =
- AbstractGrpcProtocol.reader(codec, decodeFrame)
+ override protected def reader(codec: Codec, maxInboundMessageSize: Int):
GrpcProtocolReader =
+ AbstractGrpcProtocol.reader(codec, decodeFrame, maxInboundMessageSize =
maxInboundMessageSize)
@inline
private def decodeFrame(@nowarn("msg=is never used") frameType: Int, data:
ByteString) = DataFrame(data)
diff --git
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/GrpcProtocolWeb.scala
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/GrpcProtocolWeb.scala
index 1912ae77..2b3fdcbd 100644
---
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/GrpcProtocolWeb.scala
+++
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/GrpcProtocolWeb.scala
@@ -32,8 +32,8 @@ abstract class GrpcProtocolWebBase(subType: String) extends
AbstractGrpcProtocol
override protected def writer(codec: Codec): GrpcProtocolWriter =
AbstractGrpcProtocol.writer(this, codec, frame => encodeFrame(codec,
frame), encodeDataToResponse(codec))
- override protected def reader(codec: Codec): GrpcProtocolReader =
- AbstractGrpcProtocol.reader(codec, decodeFrame, preDecodeStrict,
preDecodeFlow)
+ override protected def reader(codec: Codec, maxInboundMessageSize: Int):
GrpcProtocolReader =
+ AbstractGrpcProtocol.reader(codec, decodeFrame, preDecodeStrict,
preDecodeFlow, maxInboundMessageSize)
private def encodeFrame(codec: Codec, frame: Frame): ChunkStreamPart =
Chunk(postEncode(encodeFrameToBytes(codec, frame)))
diff --git a/runtime/src/main/scala/org/apache/pekko/grpc/internal/Gzip.scala
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/Gzip.scala
index e8f80f99..21d3687b 100644
--- a/runtime/src/main/scala/org/apache/pekko/grpc/internal/Gzip.scala
+++ b/runtime/src/main/scala/org/apache/pekko/grpc/internal/Gzip.scala
@@ -17,6 +17,9 @@ import java.io.ByteArrayOutputStream
import java.util.zip.{ GZIPInputStream, GZIPOutputStream }
import org.apache.pekko.util.ByteString
+import io.grpc.{ Status, StatusException }
+
+import scala.annotation.nowarn
object Gzip extends Codec {
override val name: String = "gzip"
@@ -29,6 +32,7 @@ object Gzip extends Codec {
ByteString.fromArrayUnsafe(baos.toByteArray)
}
+ @deprecated("Use uncompress(bytes, maxDecompressedSize), which bounds the
decompressed size", "2.0.0")
override def uncompress(compressed: ByteString): ByteString = {
val gzis = new GZIPInputStream(compressed.asInputStream)
@@ -44,6 +48,41 @@ object Gzip extends Codec {
ByteString.fromArrayUnsafe(baos.toByteArray)
}
+ /**
+ * Decompress with a maximum decompressed size limit.
+ * Checks cumulative output size during decompression to fail fast
+ * before allocating excessive memory.
+ */
+ override def uncompress(compressed: ByteString, maxDecompressedSize: Int):
ByteString = {
+ val limit = maxDecompressedSize.toLong
+ // clamp: maxDecompressedSize is validated to be positive by the settings
classes, but this
+ // method is also reachable with a hand-constructed limit, and a negative
initial size would
+ // make ByteArrayOutputStream throw IllegalArgumentException rather than a
gRPC status.
+ val initialSize = Math.max(0L, Math.min(compressed.size.toLong,
limit)).toInt
+ val gzis = new GZIPInputStream(compressed.asInputStream)
+ val baos = new ByteArrayOutputStream(initialSize)
+ val buffer = new Array[Byte](32 * 1024)
+ // Long, so that a limit close to Int.MaxValue cannot be passed by an
overflowing counter
+ var totalBytes = 0L
+ try {
+ var read = gzis.read(buffer)
+ while (read != -1) {
+ totalBytes += read
+ if (totalBytes > limit)
+ throw new StatusException(
+ Status.RESOURCE_EXHAUSTED.withDescription(
+ s"Decompressed message size exceeds maximum allowed
$maxDecompressedSize bytes"))
+ baos.write(buffer, 0, read)
+ read = gzis.read(buffer)
+ }
+ } finally gzis.close()
+ ByteString.fromArrayUnsafe(baos.toByteArray)
+ }
+
+ @deprecated(
+ "Use uncompress(compressedBitSet, bytes, maxDecompressedSize), which
bounds the decompressed size",
+ "2.0.0")
+ @nowarn("cat=deprecation")
override def uncompress(compressedBitSet: Boolean, bytes: ByteString):
ByteString =
if (compressedBitSet) uncompress(bytes)
else bytes
diff --git
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/Identity.scala
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/Identity.scala
index 281f5b6b..e236096f 100644
--- a/runtime/src/main/scala/org/apache/pekko/grpc/internal/Identity.scala
+++ b/runtime/src/main/scala/org/apache/pekko/grpc/internal/Identity.scala
@@ -21,11 +21,32 @@ object Identity extends Codec {
override def compress(bytes: ByteString): ByteString = bytes
+ @deprecated("Use uncompress(bytes, maxDecompressedSize), which bounds the
decompressed size", "2.0.0")
override def uncompress(bytes: ByteString): ByteString = bytes
+ @deprecated(
+ "Use uncompress(compressedBitSet, bytes, maxDecompressedSize), which
bounds the decompressed size",
+ "2.0.0")
override def uncompress(compressedBitSet: Boolean, bytes: ByteString):
ByteString =
if (compressedBitSet)
throw new StatusException(
Status.INTERNAL.withDescription("Compressed-Flag bit is set, but a
compression encoding is not specified"))
else bytes
+
+ override def uncompress(bytes: ByteString, maxDecompressedSize: Int):
ByteString =
+ if (bytes.length > maxDecompressedSize)
+ throw new StatusException(
+ Status.RESOURCE_EXHAUSTED.withDescription(
+ s"Message size ${bytes.length} exceeds maximum allowed
$maxDecompressedSize bytes"))
+ else bytes
+
+ override def uncompress(compressedBitSet: Boolean, bytes: ByteString,
maxDecompressedSize: Int): ByteString =
+ if (compressedBitSet)
+ throw new StatusException(
+ Status.INTERNAL.withDescription("Compressed-Flag bit is set, but a
compression encoding is not specified"))
+ else if (bytes.length > maxDecompressedSize)
+ throw new StatusException(
+ Status.RESOURCE_EXHAUSTED.withDescription(
+ s"Message size ${bytes.length} exceeds maximum allowed
$maxDecompressedSize bytes"))
+ else bytes
}
diff --git
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/NettyClientUtils.scala
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/NettyClientUtils.scala
index d8d89091..a078af9b 100644
---
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/NettyClientUtils.scala
+++
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/NettyClientUtils.scala
@@ -101,6 +101,8 @@ object NettyClientUtils {
builder =
settings.loadBalancingPolicy.map(builder.defaultLoadBalancingPolicy(_)).getOrElse(builder)
builder =
settings.overrideAuthority.map(builder.overrideAuthority(_)).getOrElse(builder)
builder = settings.userAgent.map(builder.userAgent(_)).getOrElse(builder)
+ builder = builder.maxInboundMessageSize(settings.maxInboundMessageSize)
+ // applied last so that explicit overrides win over anything derived from
settings
builder = settings.channelBuilderOverrides(builder)
val connectionAttempts = settings.loadBalancingPolicy match {
diff --git
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtils.scala
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtils.scala
index 91b8ab15..068c8f34 100644
---
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtils.scala
+++
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtils.scala
@@ -187,7 +187,9 @@ object PekkoHttpClientUtils {
descriptor.getFullMethodName),
GrpcEntityHelpers.metadataHeaders(headers.entries),
source)
- applyDeadline(responseToSource(singleRequest(httpRequest),
deserializer), options)
+ applyDeadline(
+ responseToSource(singleRequest(httpRequest), deserializer,
settings.maxInboundMessageSize),
+ options)
}
}
}
@@ -286,7 +288,10 @@ object PekkoHttpClientUtils {
* INTERNAL API
*/
@InternalApi
- def responseToSource[O](response: Future[HttpResponse], deserializer:
ProtobufSerializer[O])(
+ def responseToSource[O](
+ response: Future[HttpResponse],
+ deserializer: ProtobufSerializer[O],
+ maxInboundMessageSize: Int =
AbstractGrpcProtocol.DefaultMaxInboundMessageSize)(
implicit ec: ExecutionContext,
mat: Materializer): Source[O, Future[GrpcResponseMetadata]] = {
Source.lazyFutureSource[O, Future[GrpcResponseMetadata]](() => {
@@ -299,7 +304,8 @@ object PekkoHttpClientUtils {
} else {
Codecs.detect(response) match {
case Success(codec) =>
- implicit val reader: GrpcProtocolReader =
GrpcProtocolNative.newReader(codec)
+ implicit val reader: GrpcProtocolReader =
+ GrpcProtocolNative.newReader(codec, maxInboundMessageSize)
val trailerPromise = Promise[immutable.Seq[HttpHeader]]()
// Completed with success or failure based on grpc-status and
grpc-message trailing headers
val completionFuture: Future[Unit] =
diff --git
a/runtime/src/main/scala/org/apache/pekko/grpc/javadsl/GrpcMarshalling.scala
b/runtime/src/main/scala/org/apache/pekko/grpc/javadsl/GrpcMarshalling.scala
index 18c49637..1d685036 100644
--- a/runtime/src/main/scala/org/apache/pekko/grpc/javadsl/GrpcMarshalling.scala
+++ b/runtime/src/main/scala/org/apache/pekko/grpc/javadsl/GrpcMarshalling.scala
@@ -46,6 +46,27 @@ object GrpcMarshalling {
}
.fold(Optional.empty[CompletionStage[T]])(Optional.of)
+ /**
+ * INTERNAL API
+ *
+ * Negotiates the gRPC protocol with a custom maximum inbound message size.
+ *
+ * Deliberately not an overload of `negotiated`: a second overload would
stop Scala from
+ * inferring the parameter types of the `(reader, writer) => ...` lambda at
existing call sites.
+ */
+ @InternalApi
+ def negotiatedWithMaxSize[T](
+ req: HttpRequest,
+ maxInboundMessageSize: Int,
+ f: (GrpcProtocolReader, GrpcProtocolWriter) => CompletionStage[T]):
Optional[CompletionStage[T]] =
+ GrpcProtocol
+ .negotiate(req, maxInboundMessageSize)
+ .map {
+ case (maybeReader, writer) =>
+ maybeReader.map(reader => f(reader,
writer)).fold[CompletionStage[T]](failure, identity)
+ }
+ .fold(Optional.empty[CompletionStage[T]])(Optional.of)
+
def unmarshal[T](
data: Source[ByteString, AnyRef],
u: ProtobufSerializer[T],
diff --git
a/runtime/src/main/scala/org/apache/pekko/grpc/scaladsl/GrpcMarshalling.scala
b/runtime/src/main/scala/org/apache/pekko/grpc/scaladsl/GrpcMarshalling.scala
index 42b248fe..dd316325 100644
---
a/runtime/src/main/scala/org/apache/pekko/grpc/scaladsl/GrpcMarshalling.scala
+++
b/runtime/src/main/scala/org/apache/pekko/grpc/scaladsl/GrpcMarshalling.scala
@@ -60,6 +60,24 @@ object GrpcMarshalling {
case (Failure(ex), _) => Future.failed(ex)
}
+ /**
+ * INTERNAL API
+ *
+ * Negotiates the gRPC protocol with a custom maximum inbound message size.
+ *
+ * Deliberately not an overload of `negotiated`: a second overload would
stop Scala from
+ * inferring the parameter types of the `(reader, writer) => ...` lambda at
existing call sites.
+ */
+ @InternalApi
+ def negotiatedWithMaxSize[T](
+ req: HttpRequest,
+ maxInboundMessageSize: Int,
+ f: (GrpcProtocolReader, GrpcProtocolWriter) => Future[T]):
Option[Future[T]] =
+ GrpcProtocol.negotiate(req, maxInboundMessageSize).map {
+ case (Success(reader), writer) => f(reader, writer)
+ case (Failure(ex), _) => Future.failed(ex)
+ }
+
def unmarshal[T](data: Source[ByteString, Any])(
implicit u: ProtobufSerializer[T],
mat: Materializer,
diff --git
a/runtime/src/test/scala/org/apache/pekko/grpc/GrpcClientSettingsSpec.scala
b/runtime/src/test/scala/org/apache/pekko/grpc/GrpcClientSettingsSpec.scala
index 22f87a7b..bd40b43c 100644
--- a/runtime/src/test/scala/org/apache/pekko/grpc/GrpcClientSettingsSpec.scala
+++ b/runtime/src/test/scala/org/apache/pekko/grpc/GrpcClientSettingsSpec.scala
@@ -122,6 +122,49 @@ class GrpcClientSettingsSpec extends AnyWordSpec with
Matchers with ScalaFutures
ActorSystem("test",
clientConfig.withFallback(defaultConfig).withFallback(clientWithServiceDiscovery))
}
+ "default to the same maximum inbound message size as grpc-java" in {
+ GrpcClientSettings.connectToServiceAt("host.com",
8080).maxInboundMessageSize should be(4 * 1024 * 1024)
+ }
+
+ "support overriding the maximum inbound message size" in {
+ GrpcClientSettings
+ .connectToServiceAt("host.com", 8080)
+ .withMaxInboundMessageSize(8388608)
+ .maxInboundMessageSize should be(8388608)
+ }
+
+ "reject a non-positive maximum inbound message size" in {
+ intercept[IllegalArgumentException](
+ GrpcClientSettings.connectToServiceAt("host.com",
8080).withMaxInboundMessageSize(0))
+ intercept[IllegalArgumentException](
+ GrpcClientSettings.connectToServiceAt("host.com",
8080).withMaxInboundMessageSize(-1))
+ }
+
+ "read the maximum inbound message size from config" in {
+ val config = ConfigFactory
+ .parseString("""
+ host = "host.com"
+ port = 8080
+ max-inbound-message-size = 8388608
+ """)
+
.withFallback(sys.settings.config.getConfig("""pekko.grpc.client."*""""))
+
+ GrpcClientSettings.fromConfig(config).maxInboundMessageSize should
be(8388608)
+ }
+
+ "fall back on the default maximum inbound message size for a Config
without the key" in {
+ // fromConfig(Config) is public API and is called with hand-assembled
Configs
+ val config = ConfigFactory
+ .parseString("""
+ host = "host.com"
+ port = 8080
+ """)
+
.withFallback(sys.settings.config.getConfig("""pekko.grpc.client."*""""))
+ .withoutPath("max-inbound-message-size")
+
+ GrpcClientSettings.fromConfig(config).maxInboundMessageSize should be(4
* 1024 * 1024)
+ }
+
"use static service discovery for connectToServiceAt" in {
val settings = GrpcClientSettings.connectToServiceAt("host.com", 8080)
val resolved = settings.serviceDiscovery.lookup("any",
1.second).futureValue
diff --git
a/runtime/src/test/scala/org/apache/pekko/grpc/GrpcServerSettingsSpec.scala
b/runtime/src/test/scala/org/apache/pekko/grpc/GrpcServerSettingsSpec.scala
new file mode 100644
index 00000000..259359e5
--- /dev/null
+++ b/runtime/src/test/scala/org/apache/pekko/grpc/GrpcServerSettingsSpec.scala
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.grpc
+
+import org.apache.pekko
+import pekko.actor.ActorSystem
+import pekko.grpc.internal.AbstractGrpcProtocol
+import pekko.testkit.TestKit
+import com.typesafe.config.ConfigFactory
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.wordspec.AnyWordSpecLike
+
+class GrpcServerSettingsSpec extends TestKit(ActorSystem()) with
AnyWordSpecLike with Matchers {
+
+ "GrpcServerSettings" should {
+
+ "default to the same maximum inbound message size as grpc-java" in {
+ GrpcServerSettings(system).maxInboundMessageSize shouldBe 4 * 1024 * 1024
+ GrpcServerSettings(system).maxInboundMessageSize shouldBe
AbstractGrpcProtocol.DefaultMaxInboundMessageSize
+ }
+
+ "read the maximum inbound message size from config" in {
+ val config = ConfigFactory.parseString("max-inbound-message-size =
8388608")
+
+ GrpcServerSettings.fromConfig(config).maxInboundMessageSize shouldBe
8388608
+ }
+
+ "fall back on the default when the key is absent" in {
+ GrpcServerSettings
+ .fromConfig(ConfigFactory.empty())
+ .maxInboundMessageSize shouldBe
AbstractGrpcProtocol.DefaultMaxInboundMessageSize
+ }
+
+ "support overriding the maximum inbound message size" in {
+
GrpcServerSettings(system).withMaxInboundMessageSize(1234).maxInboundMessageSize
shouldBe 1234
+ }
+
+ "reject a non-positive maximum inbound message size" in {
+
intercept[IllegalArgumentException](GrpcServerSettings(system).withMaxInboundMessageSize(0))
+
intercept[IllegalArgumentException](GrpcServerSettings(system).withMaxInboundMessageSize(-1))
+ intercept[IllegalArgumentException](
+
GrpcServerSettings.fromConfig(ConfigFactory.parseString("max-inbound-message-size
= 0")))
+ }
+
+ "expose the same value via the Java API" in {
+ GrpcServerSettings.create(system).maxInboundMessageSize shouldBe
GrpcServerSettings(system).maxInboundMessageSize
+ }
+ }
+}
diff --git
a/runtime/src/test/scala/org/apache/pekko/grpc/internal/MaxInboundMessageSizeSpec.scala
b/runtime/src/test/scala/org/apache/pekko/grpc/internal/MaxInboundMessageSizeSpec.scala
new file mode 100644
index 00000000..a484c662
--- /dev/null
+++
b/runtime/src/test/scala/org/apache/pekko/grpc/internal/MaxInboundMessageSizeSpec.scala
@@ -0,0 +1,206 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.grpc.internal
+
+import java.io.ByteArrayOutputStream
+import java.util.zip.GZIPOutputStream
+
+import org.apache.pekko
+import pekko.actor.ActorSystem
+import pekko.grpc.GrpcProtocol.{ DataFrame, Frame }
+import pekko.stream.scaladsl.Source
+import pekko.stream.testkit.scaladsl.TestSink
+import pekko.testkit.TestKit
+import pekko.util.ByteString
+import io.grpc.{ Status, StatusException }
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.wordspec.AnyWordSpecLike
+
+class MaxInboundMessageSizeSpec extends TestKit(ActorSystem()) with
AnyWordSpecLike with Matchers {
+
+ private val Limit = 64 * 1024
+
+ /** Inflates to far more than `Limit`, but compresses to far less, so only a
bounded decompressor catches it. */
+ private val BombSize = 4 * 1024 * 1024
+
+ private val TooLargeWhenDecompressed = s"Decompressed message size exceeds
maximum allowed $Limit bytes"
+
+ /** A frame header: 1 byte flags + 4 bytes big-endian length. */
+ private def frameHeader(flags: Byte, length: Int): ByteString = {
+ val header = new Array[Byte](5)
+ header(0) = flags
+ header(1) = (length >>> 24).toByte
+ header(2) = (length >>> 16).toByte
+ header(3) = (length >>> 8).toByte
+ header(4) = length.toByte
+ ByteString.fromArrayUnsafe(header, 0, 5)
+ }
+
+ private def gzipped(bytes: ByteString): ByteString = {
+ val baos = new ByteArrayOutputStream()
+ val gzos = new GZIPOutputStream(baos)
+ try gzos.write(bytes.toArrayUnsafe())
+ finally gzos.close()
+ ByteString.fromArrayUnsafe(baos.toByteArray)
+ }
+
+ /** Highly compressible payload. */
+ private def zeros(size: Int): ByteString = ByteString.fromArrayUnsafe(new
Array[Byte](size))
+
+ private def bomb: ByteString = gzipped(zeros(BombSize))
+
+ private def streamingError(codec: Codec, bytes: ByteString): StatusException
=
+ Source
+ .single(bytes)
+ .via(GrpcProtocolNative.newReader(codec, Limit).frameDecoder)
+ .runWith(TestSink[Frame]())
+ .request(1)
+ .expectError() match {
+ case s: StatusException => s
+ case other => fail(s"expected a StatusException, got
[$other]")
+ }
+
+ private def strictError(codec: Codec, bytes: ByteString): StatusException =
+ intercept[StatusException](GrpcProtocolNative.newReader(codec,
Limit).decodeSingleFrame(bytes))
+
+ "The streaming frame decoder" should {
+
+ "reject a frame whose declared length exceeds the limit" in {
+ // header only: the oversized payload is never sent, so this can only be
detected
+ // from the declared length, before any of it is buffered
+ val status = streamingError(Identity, frameHeader(0, Limit +
1)).getStatus
+
+ status.getCode shouldBe Status.Code.RESOURCE_EXHAUSTED
+ status.getDescription should include(s"Frame length ${Limit + 1}")
+ }
+
+ "reject a negative frame length with INTERNAL" in {
+ streamingError(Identity, frameHeader(0, -1)).getStatus.getCode shouldBe
Status.Code.INTERNAL
+ }
+
+ "accept a frame exactly at the limit" in {
+ val data = zeros(Limit)
+
+ Source
+ .single(frameHeader(0, Limit) ++ data)
+ .via(GrpcProtocolNative.newReader(Identity, Limit).frameDecoder)
+ .runWith(TestSink[Frame]())
+ .request(1)
+ .expectNext(DataFrame(data))
+ .expectComplete()
+ }
+
+ "reject a gzip bomb while decompressing rather than after" in {
+ val compressed = bomb
+ // the frame itself is well within the limit, so the frame length check
cannot catch this
+ compressed.length should be < Limit
+
+ val status = streamingError(Gzip, frameHeader(1, compressed.length) ++
compressed).getStatus
+
+ status.getCode shouldBe Status.Code.RESOURCE_EXHAUSTED
+ // This message is produced only by Gzip's streaming, size-bounded
decoder. The generic
+ // Codec fallback decompresses in full first and reports the actual
size, so asserting
+ // on the exact text pins the fail-fast path rather than merely the
outcome.
+ status.getDescription shouldBe TooLargeWhenDecompressed
+ }
+ }
+
+ "The strict frame decoder" should {
+
+ "reject a frame whose declared length exceeds the limit" in {
+ val status = strictError(Identity, frameHeader(0, Limit + 1) ++
zeros(Limit + 1)).getStatus
+
+ status.getCode shouldBe Status.Code.RESOURCE_EXHAUSTED
+ status.getDescription should include(s"Frame length ${Limit + 1}")
+ }
+
+ "reject a negative frame length with INTERNAL" in {
+ strictError(Identity, frameHeader(0, -1)).getStatus.getCode shouldBe
Status.Code.INTERNAL
+ }
+
+ "reject a gzip bomb while decompressing rather than after" in {
+ val compressed = bomb
+ val status = strictError(Gzip, frameHeader(1, compressed.length) ++
compressed).getStatus
+
+ status.getCode shouldBe Status.Code.RESOURCE_EXHAUSTED
+ status.getDescription shouldBe TooLargeWhenDecompressed
+ }
+
+ "decode a frame within the limit" in {
+ val data = zeros(16)
+
+ GrpcProtocolNative.newReader(Identity,
Limit).decodeSingleFrame(frameHeader(0, 16) ++ data) shouldBe data
+ }
+ }
+
+ "Gzip" should {
+
+ "route the compression-bit overload to the size-bounded decoder" in {
+ val status = intercept[StatusException](Gzip.uncompress(compressedBitSet
= true, bomb, Limit)).getStatus
+
+ status.getCode shouldBe Status.Code.RESOURCE_EXHAUSTED
+ status.getDescription shouldBe TooLargeWhenDecompressed
+ }
+
+ "decompress payloads within the limit" in {
+ val data = zeros(Limit)
+
+ Gzip.uncompress(compressedBitSet = true, gzipped(data), Limit) shouldBe
data
+ }
+
+ "not reject a payload when the limit is Int.MaxValue" in {
+ val data = zeros(64 * 1024)
+
+ Gzip.uncompress(gzipped(data), Int.MaxValue) shouldBe data
+ }
+
+ "report a non-positive limit as a gRPC status rather than
IllegalArgumentException" in {
+ val status =
intercept[StatusException](Gzip.uncompress(gzipped(zeros(16)), -1)).getStatus
+
+ status.getCode shouldBe Status.Code.RESOURCE_EXHAUSTED
+ }
+ }
+
+ "Identity" should {
+
+ "reject a payload larger than the limit" in {
+ val status =
+ intercept[StatusException](Identity.uncompress(compressedBitSet =
false, zeros(Limit + 1), Limit)).getStatus
+
+ status.getCode shouldBe Status.Code.RESOURCE_EXHAUSTED
+ }
+
+ "still reject a set compression bit with INTERNAL" in {
+ val status =
intercept[StatusException](Identity.uncompress(compressedBitSet = true,
zeros(1), Limit)).getStatus
+
+ status.getCode shouldBe Status.Code.INTERNAL
+ }
+ }
+
+ "newReader" should {
+
+ "reuse a cached reader for the default limit" in {
+ assert(GrpcProtocolNative.newReader(Identity) eq
GrpcProtocolNative.newReader(Identity))
+ assert(GrpcProtocolWeb.newReader(Gzip) eq
GrpcProtocolWeb.newReader(Gzip))
+ }
+
+ "build a fresh reader for a custom limit" in {
+ assert(GrpcProtocolNative.newReader(Identity, Limit) ne
GrpcProtocolNative.newReader(Identity))
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]