From 1c34209bfbb35f7f1eda80518e0b08582d4b0191 Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Mon, 31 Aug 2026 17:59:17 +0100 Subject: [PATCH] feat: allow empty HTTP/2 DATA frames to be throttled Motivation: A DATA frame carrying no payload has `sizeInWindow == 0`, so it consumes no flow-control window. The number of such frames is therefore not bounded by flow control at all, and on an open stream a peer can send them continuously, each one costing a pass through the stream state machine and a flow-control recompute. The `frame-type-throttle` mechanism could not be pointed at them: it maps an alias to a frame type name and had no alias for DATA, so DATA frames were unthrottleable by configuration. Adding a plain "data" alias would not have helped. Throttling every DATA frame at the configured rate throttles legitimate throughput with it, so nobody could enable it for this purpose. Only the empty frames need it, because the data-carrying ones are already bounded by flow control. Modification: Add an "empty-data" alias for the throttle that charges DATA frames whose payload is empty. It resolves to a name that is deliberately not a real frame type name, which `frameCost` recognises and applies to empty DATA frames only. Off by default, like every throttle target other than "reset". Result: An operator can throttle a flood of empty DATA frames without throttling data-carrying ones. Tests: - sbt "http-core/testOnly org.apache.pekko.http.impl.engine.http2.Http2BlueprintSpec" - pass (11 tests), with a new case for the alias. - sbt "http2-tests/testOnly org.apache.pekko.http.impl.engine.http2.Http2ServerEmptyDataThrottleSpec org.apache.pekko.http.impl.engine.http2.Http2ServerEnableFrameTypeThrottleSpec org.apache.pekko.http.impl.engine.http2.Http2ServerDisableFrameTypeThrottleSpec" - pass (3 tests); the new spec floods empty DATA frames with the throttle configured for them and requires the connection to be torn down. Verified it fails when the alias resolves to None, which is the behaviour before this change. - sbt http-core/mimaReportBinaryIssues - pass References: Refs #332 - extends the frame type throttle to empty DATA frames --- http-core/src/main/resources/reference.conf | 8 +++- .../impl/engine/http2/Http2Blueprint.scala | 19 ++++++-- .../engine/http2/Http2BlueprintSpec.scala | 4 ++ .../Http2ServerEmptyDataThrottleSpec.scala | 43 +++++++++++++++++++ 4 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ServerEmptyDataThrottleSpec.scala diff --git a/http-core/src/main/resources/reference.conf b/http-core/src/main/resources/reference.conf index 0767de86bd..2e75822dcc 100644 --- a/http-core/src/main/resources/reference.conf +++ b/http-core/src/main/resources/reference.conf @@ -332,8 +332,14 @@ pekko.http { frame-type-throttle { # Configure the throttle for non-data frame types (https://github.com/apache/pekko-http/issues/332). # The supported frame-types for throttling are: - # reset, headers, continuation, go-away, priority, ping, push-promise, window-update + # reset, headers, continuation, go-away, priority, ping, push-promise, window-update, empty-data # By default, RST_STREAM frames are throttled to mitigate HTTP/2 Rapid Reset attacks (CVE-2023-44487). + # + # "empty-data" covers DATA frames that carry no payload. Those consume no flow-control window, so unlike + # data-carrying frames their number is not bounded by flow control and a peer can send them continuously. + # Only the empty ones can be throttled: doing the same for data-carrying frames would throttle legitimate + # throughput along with them. + # # Set to [] to disable throttling. frame-types = ["reset"] cost = 100 diff --git a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2Blueprint.scala b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2Blueprint.scala index aa7de7bda2..defd8325e7 100644 --- a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2Blueprint.scala +++ b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2Blueprint.scala @@ -206,9 +206,15 @@ private[http] object Http2Blueprint { private def rapidResetMitigation(settings: Http2ServerSettings, frameTypesForThrottle: Set[String]): BidiFlow[FrameEvent, FrameEvent, FrameEvent, FrameEvent, NotUsed] = { - def frameCost(event: FrameEvent): Int = { - if (frameTypesForThrottle.contains(event.frameTypeName)) 1 else 0 - } + def frameCost(event: FrameEvent): Int = + if (frameTypesForThrottle.contains(event.frameTypeName)) 1 + else event match { + // A DATA frame with no payload consumes no flow-control window, so unlike a data-carrying one its number is + // not bounded by flow control at all. It is matched separately because throttling every DATA frame would + // throttle legitimate throughput along with it. + case d: DataFrame if d.payload.isEmpty && frameTypesForThrottle.contains(EmptyDataFrameThrottleName) => 1 + case _ => 0 + } BidiFlow.fromFlows( Flow[FrameEvent], @@ -225,8 +231,15 @@ private[http] object Http2Blueprint { } } + /** + * Not a real `frameTypeName`, so it never matches one directly: `frameCost` recognises it and charges DATA frames + * that carry no payload. + */ + private[http2] val EmptyDataFrameThrottleName = "EmptyDataFrame" + private[http2] def frameTypeAliasToFrameTypeName(frameType: String): Option[String] = { toRootLowerCase(frameType) match { + case "empty-data" => Some(EmptyDataFrameThrottleName) case "reset" => Some("RstStreamFrame") case "headers" => Some("HeadersFrame") case "continuation" => Some("ContinuationFrame") diff --git a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2BlueprintSpec.scala b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2BlueprintSpec.scala index 4858c3149d..a7ffe2eb23 100644 --- a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2BlueprintSpec.scala +++ b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2BlueprintSpec.scala @@ -26,6 +26,10 @@ import org.scalatest.wordspec.AnyWordSpec class Http2BlueprintSpec extends AnyWordSpec with Matchers { "Http2Blueprint" should { + "match frame type alias (empty-data)" in { + Http2Blueprint.frameTypeAliasToFrameTypeName("empty-data") shouldEqual + Some(Http2Blueprint.EmptyDataFrameThrottleName) + } "match frame type alias (reset)" in { Http2Blueprint.frameTypeAliasToFrameTypeName("reset") shouldEqual Some(RstStreamFrame(0, ErrorCode.PROTOCOL_ERROR).frameTypeName) diff --git a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ServerEmptyDataThrottleSpec.scala b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ServerEmptyDataThrottleSpec.scala new file mode 100644 index 0000000000..012da14a75 --- /dev/null +++ b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ServerEmptyDataThrottleSpec.scala @@ -0,0 +1,43 @@ +/* + * 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.http.impl.engine.http2 + +import org.apache.pekko +import pekko.http.impl.engine.http2.Http2Protocol.FrameType +import pekko.http.impl.engine.http2.framing.FrameRenderer +import pekko.util.ByteString + +/** + * Tests that DATA frames carrying no payload can be throttled. They consume no flow-control window, so unlike + * data-carrying frames their number is not bounded by flow control. + */ +class Http2ServerEmptyDataThrottleSpec extends Http2SpecWithMaterializer(""" + pekko.http.server.http2.log-frames = on + pekko.http.server.http2.frame-type-throttle.frame-types = ["empty-data"] + """) { + override val expectSevereLogsOnlyToMatch: Option[String] = Some( + "HTTP2 connection failed with error [Maximum throttle throughput exceeded.]. Sending INTERNAL_ERROR and closing connection.") + + "The Http/2 server implementation" should { + "cancel connection when flooded with empty DATA frames".inAssertAllStagesStopped( + new TestSetup with RequestResponseProbes { + val emptyDataFrame = FrameRenderer.renderFrame(FrameType.DATA, ByteFlag.Zero, 1, ByteString.empty) + network.sendBytes(Seq.fill(1000)(emptyDataFrame).reduce(_ ++ _)) + }) + } +}