From e8cd3ad3fbaf3902f49065b6081cafb02c077825 Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Fri, 28 Aug 2026 21:06:24 +0100 Subject: [PATCH 1/4] http/2: bound incoming header blocks with max-header-list-size (#1216) Motivation: The HTTP/2 header decompression stage had no upper bound on the incoming side. The HPACK decoder was constructed with Http2Protocol.InitialMaxHeaderListSize (Int.MaxValue) and the header block fragments of a HEADERS frame and its CONTINUATION frames were accumulated until END_HEADERS was seen, so the memory used for a single header block was limited only by what the peer chose to send. Neither endpoint advertised SETTINGS_MAX_HEADER_LIST_SIZE, so a peer had no way of knowing what it may send either. Modification: Add a `max-header-list-size` setting (64 KiB by default) to `pekko.http.server.http2` and `pekko.http.client.http2` and pass it to `HeaderDecompression`, which now * constructs the HPACK decoder with that limit and checks the truncation result of `Decoder.endHeaderBlock()`, which was previously ignored, * applies the same limit to the accumulated header block fragments, accounting each fragment with its frame header size so that the number of empty CONTINUATION frames per header block is bounded as well, * fails the connection with GOAWAY(ENHANCE_YOUR_CALM) when the limit is exceeded. The configured value is advertised to the peer in the initial SETTINGS frame. Result: The memory used for a single incoming header block is bounded by the configured limit on both the server and the client side, and peers are told about the limit up front. Tests: - sbt "http2-tests/testOnly org.apache.pekko.http.impl.engine.http2.Http2ServerSpec" - pass, 5 new tests - sbt http2-tests/test - pass - sbt http-core/test - pass (HostConnectionPoolSpec flaked in the full run, passes on its own) - sbt +http-core/mimaReportBinaryIssues - pass - sbt http-core/scalafmt http2-tests/Test/scalafmt - clean - sbt http-core/headerCreateAll - no changes References: None - bounds the memory used for incoming HTTP/2 header blocks --- http-core/src/main/resources/reference.conf | 30 ++++++++ .../impl/engine/http2/Http2Blueprint.scala | 8 +-- .../http/impl/engine/http2/Http2Demux.scala | 3 +- .../http2/hpack/HeaderDecompression.scala | 51 +++++++++++--- .../settings/Http2ClientSettings.scala | 14 ++++ .../settings/Http2ServerSettings.scala | 14 ++++ .../settings/Http2ServerSettings.scala | 35 ++++++++++ .../impl/engine/http2/Http2ServerSpec.scala | 69 +++++++++++++++++++ .../engine/http2/RequestParsingSpec.scala | 27 ++++---- 9 files changed, 224 insertions(+), 27 deletions(-) diff --git a/http-core/src/main/resources/reference.conf b/http-core/src/main/resources/reference.conf index 9fe7894faa..8b47a7719d 100644 --- a/http-core/src/main/resources/reference.conf +++ b/http-core/src/main/resources/reference.conf @@ -243,6 +243,21 @@ pekko.http { # the connection was established but before it received our SETTINGS. max-concurrent-streams = 256 + # The maximum size of the header list (the sum of the sizes of the decompressed header names and values) that + # this endpoint is prepared to accept, in bytes. The value is advertised to the peer using the + # SETTINGS_MAX_HEADER_LIST_SIZE setting. A header block that decompresses to more than this amount is rejected + # with a GOAWAY(ENHANCE_YOUR_CALM) frame instead of being buffered. + # + # The same limit is applied to the accumulated header block fragments carried by a HEADERS frame and its + # subsequent CONTINUATION frames, so that the memory used for a header block that the peer never completes + # (END_HEADERS is never set) stays bounded. Each fragment is accounted with its frame header size on top of + # its payload size, which also bounds the number of empty fragments that are accepted for one header block. + # + # Note that peers calculate the header list size with an extra overhead of 32 octets per header field (see + # RFC 9113, section 6.5.2) while this implementation only counts the actual name and value bytes, so the + # effective limit for a well-behaved peer is somewhat stricter than the configured value. + max-header-list-size = 64 KiB + # The maximum number of bytes to receive from a request entity in a single chunk. # # The reasoning to limit that amount (instead of delivering all buffered data for a stream) is that @@ -438,6 +453,21 @@ pekko.http { # the connection was established but before it received our SETTINGS. max-concurrent-streams = 256 + # The maximum size of the header list (the sum of the sizes of the decompressed header names and values) that + # this endpoint is prepared to accept, in bytes. The value is advertised to the peer using the + # SETTINGS_MAX_HEADER_LIST_SIZE setting. A header block that decompresses to more than this amount is rejected + # with a GOAWAY(ENHANCE_YOUR_CALM) frame instead of being buffered. + # + # The same limit is applied to the accumulated header block fragments carried by a HEADERS frame and its + # subsequent CONTINUATION frames, so that the memory used for a header block that the peer never completes + # (END_HEADERS is never set) stays bounded. Each fragment is accounted with its frame header size on top of + # its payload size, which also bounds the number of empty fragments that are accepted for one header block. + # + # Note that peers calculate the header list size with an extra overhead of 32 octets per header field (see + # RFC 9113, section 6.5.2) while this implementation only counts the actual name and value bytes, so the + # effective limit for a well-behaved peer is somewhat stricter than the configured value. + max-header-list-size = 64 KiB + # The maximum number of bytes to receive from a request entity in a single chunk. # # The reasoning to limit that amount (instead of delivering all buffered data for a stream) is that 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 f732085b05..7ed03461c1 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 @@ -127,7 +127,7 @@ private[http] object Http2Blueprint { httpLayer(settings, log, dateHeaderRendering) atopKeepRight serverDemux(settings.http2Settings, initialDemuxerSettings, upgraded) atop FrameLogger.logFramesIfEnabled(settings.http2Settings.logFrames) atop // enable for debugging - hpackCoding(masterHttpHeaderParser, settings.parserSettings) + hpackCoding(masterHttpHeaderParser, settings.parserSettings, settings.http2Settings.maxHeaderListSize) val frameTypesForThrottle = getFrameTypesForThrottle(settings.http2Settings) @@ -153,7 +153,7 @@ private[http] object Http2Blueprint { httpLayerClient(masterHttpHeaderParser, settings, log)).atop( clientDemux(settings.http2Settings, masterHttpHeaderParser)).atop( FrameLogger.logFramesIfEnabled(settings.http2Settings.logFrames)).atop( // enable for debugging - hpackCoding(masterHttpHeaderParser, settings.parserSettings)).atop( + hpackCoding(masterHttpHeaderParser, settings.parserSettings, settings.http2Settings.maxHeaderListSize)).atop( framingClient(log)).atop( errorHandling(log)).atop( idleTimeoutIfConfigured(settings.idleTimeout)) @@ -247,11 +247,11 @@ private[http] object Http2Blueprint { * TODO: introduce another FrameEvent type that exclude HeadersFrame and ContinuationFrame from * reaching the higher-level. */ - def hpackCoding(masterHttpHeaderParser: HttpHeaderParser, parserSettings: ParserSettings) + def hpackCoding(masterHttpHeaderParser: HttpHeaderParser, parserSettings: ParserSettings, maxHeaderListSize: Int) : BidiFlow[FrameEvent, FrameEvent, FrameEvent, FrameEvent, NotUsed] = BidiFlow.fromFlows( Flow[FrameEvent].via(HeaderCompression), - Flow[FrameEvent].via(new HeaderDecompression(masterHttpHeaderParser, parserSettings))) + Flow[FrameEvent].via(new HeaderDecompression(masterHttpHeaderParser, parserSettings, maxHeaderListSize))) /** * Creates substreams for every stream and manages stream state machines diff --git a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2Demux.scala b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2Demux.scala index f05bbb4468..aff2b5c846 100644 --- a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2Demux.scala +++ b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2Demux.scala @@ -295,7 +295,8 @@ private[http2] abstract class Http2Demux(http2Settings: Http2CommonSettings, // enforced immediately even before the acknowledgement is received. // Reminder: the receiver of a SETTINGS frame must process them in the order they are received. val initialLocalSettings: immutable.Seq[Setting] = immutable.Seq( - Setting(SettingIdentifier.SETTINGS_MAX_CONCURRENT_STREAMS, http2Settings.maxConcurrentStreams)) ++ + Setting(SettingIdentifier.SETTINGS_MAX_CONCURRENT_STREAMS, http2Settings.maxConcurrentStreams), + Setting(SettingIdentifier.SETTINGS_MAX_HEADER_LIST_SIZE, http2Settings.maxHeaderListSize)) ++ immutable.Seq(Setting(SettingIdentifier.SETTINGS_ENABLE_PUSH, 0)).filter(_ => !isServer) // only on client override def preStart(): Unit = { diff --git a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderDecompression.scala b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderDecompression.scala index a22e7c6c4b..4f488614de 100644 --- a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderDecompression.scala +++ b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderDecompression.scala @@ -35,13 +35,23 @@ import scala.collection.immutable.VectorBuilder * INTERNAL API * * Can be used on server and client side. + * + * @param maxHeaderListSize the maximum size of a decoded header list. The same limit is applied to the accumulated + * header block fragments of a HEADERS frame and its CONTINUATION frames, so that the memory + * used for a header block that the peer never completes stays bounded + * (see RFC 9113, section 10.5). */ @InternalApi -private[http2] final class HeaderDecompression(masterHeaderParser: HttpHeaderParser, parserSettings: ParserSettings) +private[http2] final class HeaderDecompression(masterHeaderParser: HttpHeaderParser, parserSettings: ParserSettings, + maxHeaderListSize: Int) extends GraphStage[FlowShape[FrameEvent, FrameEvent]] { val UTF8 = StandardCharsets.UTF_8 val US_ASCII = StandardCharsets.US_ASCII + // Each fragment is accounted with the size of its frame header on top of its payload size. Without that, empty + // CONTINUATION frames would never add to the accumulated header block and their number would be unbounded. + private val FrameHeaderSize = 9 + val eventsIn = Inlet[FrameEvent]("HeaderDecompression.eventsIn") val eventsOut = Outlet[FrameEvent]("HeaderDecompression.eventsOut") @@ -50,8 +60,8 @@ private[http2] final class HeaderDecompression(masterHeaderParser: HttpHeaderPar def createLogic(inheritedAttributes: Attributes): GraphStageLogic = new HandleOrPassOnStage[FrameEvent, FrameEvent](shape) { val httpHeaderParser = masterHeaderParser.createShallowCopy() - val decoder = new pekko.http.shaded.com.twitter.hpack.Decoder(Http2Protocol.InitialMaxHeaderListSize, - Http2Protocol.InitialMaxHeaderTableSize) + val decoder = + new pekko.http.shaded.com.twitter.hpack.Decoder(maxHeaderListSize, Http2Protocol.InitialMaxHeaderTableSize) become(Idle) @@ -92,16 +102,24 @@ private[http2] final class HeaderDecompression(masterHeaderParser: HttpHeaderPar } } } + val stream = payload.compact.asInputStream try { - decoder.decode(ByteStringInputStream(payload), Receiver) - decoder.endHeaderBlock() // TODO: do we have to check the result here? + decoder.decode(stream, Receiver) // only compact ByteString supports InputStream with mark/reset + // the decoder stops emitting headers as soon as the limit is exceeded and reports that here + val truncated = decoder.endHeaderBlock() - push(eventsOut, ParsedHeadersFrame(streamId, endStream, headers.result(), prioInfo)) + if (truncated) headerListSizeExceeded(streamId) + else push(eventsOut, ParsedHeadersFrame(streamId, endStream, headers.result(), prioInfo, None)) } catch { - case ex: IOException => + case ex: ParsingException => + // push details further and let RequestErrorFlow handle responding with bad request + push(eventsOut, ParsedHeadersFrame(streamId, endStream, Seq.empty, prioInfo, Some(ex.info))) + case _: IOException => // this is signalled by the decoder when it failed, we want to react to this by rendering a GOAWAY frame fail(eventsOut, new Http2Compliance.Http2ProtocolException(ErrorCode.COMPRESSION_ERROR, "Decompression failed.")) + } finally { + stream.close() } } @@ -109,6 +127,7 @@ private[http2] final class HeaderDecompression(masterHeaderParser: HttpHeaderPar val handleEvent: PartialFunction[FrameEvent, Unit] = { case HeadersFrame(streamId, endStream, endHeaders, fragment, prioInfo) => if (endHeaders) parseAndEmit(streamId, endStream, fragment, prioInfo) + else if (exceedsMaxHeaderListSize(0, fragment)) headerListSizeExceeded(streamId) else { become(new ReceivingHeaders(streamId, endStream, fragment, prioInfo)) pull(eventsIn) @@ -122,14 +141,21 @@ private[http2] final class HeaderDecompression(masterHeaderParser: HttpHeaderPar class ReceivingHeaders(streamId: Int, endStream: Boolean, initiallyReceivedData: ByteString, priorityInfo: Option[PriorityFrame]) extends State { var receivedData = initiallyReceivedData + // includes the frame headers of the fragments received so far, see `FrameHeaderSize` + var accountedSize: Long = FrameHeaderSize + initiallyReceivedData.size val handleEvent: PartialFunction[FrameEvent, Unit] = { case ContinuationFrame(`streamId`, endHeaders, payload) => - if (endHeaders) { + if (exceedsMaxHeaderListSize(accountedSize, payload)) + // Neither the HPACK decoder nor any of the checks further down the line run before the header block + // is complete, so this is the only place where the size of an unfinished header block is bounded. + headerListSizeExceeded(streamId) + else if (endHeaders) { parseAndEmit(streamId, endStream, receivedData ++ payload, priorityInfo) become(Idle) } else { receivedData ++= payload + accountedSize += FrameHeaderSize + payload.size pull(eventsIn) } case x => @@ -137,6 +163,15 @@ private[http2] final class HeaderDecompression(masterHeaderParser: HttpHeaderPar } } + def exceedsMaxHeaderListSize(accountedSize: Long, payload: ByteString): Boolean = + accountedSize + FrameHeaderSize + payload.size > maxHeaderListSize + + def headerListSizeExceeded(streamId: Int): Unit = + fail(eventsOut, + new Http2ProtocolException( + ErrorCode.ENHANCE_YOUR_CALM, + s"Header block of stream $streamId exceeded the configured max-header-list-size of $maxHeaderListSize bytes")) + def protocolError(msg: String): Unit = failStage(new Http2ProtocolException(msg)) } } diff --git a/http-core/src/main/scala/org/apache/pekko/http/javadsl/settings/Http2ClientSettings.scala b/http-core/src/main/scala/org/apache/pekko/http/javadsl/settings/Http2ClientSettings.scala index 1ac4be45aa..bfcf87be7d 100644 --- a/http-core/src/main/scala/org/apache/pekko/http/javadsl/settings/Http2ClientSettings.scala +++ b/http-core/src/main/scala/org/apache/pekko/http/javadsl/settings/Http2ClientSettings.scala @@ -34,6 +34,20 @@ trait Http2ClientSettings { self: scaladsl.settings.Http2ClientSettings.Http2Cli def maxConcurrentStreams: Int def withMaxConcurrentStreams(newValue: Int): Http2ClientSettings = copy(maxConcurrentStreams = newValue) + /** + * The maximum size of a decoded header list that this endpoint is prepared to accept, in bytes. The value is + * advertised to the peer via SETTINGS_MAX_HEADER_LIST_SIZE and the same limit is applied to the accumulated + * header block fragments of a HEADERS frame and its CONTINUATION frames. + * + * @since 2.0.0 + */ + def maxHeaderListSize: Int + + /** + * @since 2.0.0 + */ + def withMaxHeaderListSize(newValue: Int): Http2ClientSettings = copy(maxHeaderListSize = newValue) + def outgoingControlFrameBufferSize: Int def withOutgoingControlFrameBufferSize(newValue: Int): Http2ClientSettings = copy(outgoingControlFrameBufferSize = newValue) diff --git a/http-core/src/main/scala/org/apache/pekko/http/javadsl/settings/Http2ServerSettings.scala b/http-core/src/main/scala/org/apache/pekko/http/javadsl/settings/Http2ServerSettings.scala index e9830fb6b4..b7c026daa8 100644 --- a/http-core/src/main/scala/org/apache/pekko/http/javadsl/settings/Http2ServerSettings.scala +++ b/http-core/src/main/scala/org/apache/pekko/http/javadsl/settings/Http2ServerSettings.scala @@ -42,6 +42,20 @@ trait Http2ServerSettings { def getMaxConcurrentStreams: Int = maxConcurrentStreams def withMaxConcurrentStreams(newValue: Int): Http2ServerSettings + /** + * The maximum size of a decoded header list that this endpoint is prepared to accept, in bytes. The value is + * advertised to the peer via SETTINGS_MAX_HEADER_LIST_SIZE and the same limit is applied to the accumulated + * header block fragments of a HEADERS frame and its CONTINUATION frames. + * + * @since 2.0.0 + */ + def getMaxHeaderListSize: Int = maxHeaderListSize + + /** + * @since 2.0.0 + */ + def withMaxHeaderListSize(newValue: Int): Http2ServerSettings + def getOutgoingControlFrameBufferSize: Int = outgoingControlFrameBufferSize def withOutgoingControlFrameBufferSize(newValue: Int): Http2ServerSettings diff --git a/http-core/src/main/scala/org/apache/pekko/http/scaladsl/settings/Http2ServerSettings.scala b/http-core/src/main/scala/org/apache/pekko/http/scaladsl/settings/Http2ServerSettings.scala index e2c7eac5d6..40a32192b7 100644 --- a/http-core/src/main/scala/org/apache/pekko/http/scaladsl/settings/Http2ServerSettings.scala +++ b/http-core/src/main/scala/org/apache/pekko/http/scaladsl/settings/Http2ServerSettings.scala @@ -41,6 +41,7 @@ private[http] trait Http2CommonSettings { def logFrames: Boolean def maxConcurrentStreams: Int + def maxHeaderListSize: Int def outgoingControlFrameBufferSize: Int def pingInterval: FiniteDuration @@ -90,6 +91,20 @@ trait Http2ServerSettings extends javadsl.settings.Http2ServerSettings with Http def maxConcurrentStreams: Int override def withMaxConcurrentStreams(newValue: Int): Http2ServerSettings = copy(maxConcurrentStreams = newValue) + /** + * The maximum size of a decoded header list that this endpoint is prepared to accept, in bytes. The value is + * advertised to the peer via SETTINGS_MAX_HEADER_LIST_SIZE and the same limit is applied to the accumulated + * header block fragments of a HEADERS frame and its CONTINUATION frames. + * + * @since 2.0.0 + */ + def maxHeaderListSize: Int + + /** + * @since 2.0.0 + */ + override def withMaxHeaderListSize(newValue: Int): Http2ServerSettings = copy(maxHeaderListSize = newValue) + def outgoingControlFrameBufferSize: Int override def withOutgoingControlFrameBufferSize(newValue: Int): Http2ServerSettings = copy(outgoingControlFrameBufferSize = newValue) @@ -129,6 +144,7 @@ object Http2ServerSettings extends SettingsCompanion[Http2ServerSettings] { private[http] case class Http2ServerSettingsImpl( maxConcurrentStreams: Int, + maxHeaderListSize: Int, requestEntityChunkSize: Int, incomingConnectionLevelBufferSize: Int, incomingStreamLevelBufferSize: Int, @@ -144,6 +160,7 @@ object Http2ServerSettings extends SettingsCompanion[Http2ServerSettings] { internalSettings: Option[Http2InternalServerSettings]) extends Http2ServerSettings { require(maxConcurrentStreams >= 0, "max-concurrent-streams must be >= 0") + require(maxHeaderListSize > 0, "max-header-list-size must be > 0") require(requestEntityChunkSize > 0, "request-entity-chunk-size must be > 0") require(incomingConnectionLevelBufferSize > 0, "incoming-connection-level-buffer-size must be > 0") require(incomingStreamLevelBufferSize > 0, "incoming-stream-level-buffer-size must be > 0") @@ -161,6 +178,7 @@ object Http2ServerSettings extends SettingsCompanion[Http2ServerSettings] { extends pekko.http.impl.util.SettingsCompanionImpl[Http2ServerSettingsImpl]("pekko.http.server.http2") { def fromSubConfig(root: Config, c: Config): Http2ServerSettingsImpl = Http2ServerSettingsImpl( maxConcurrentStreams = c.getInt("max-concurrent-streams"), + maxHeaderListSize = c.getIntBytes("max-header-list-size"), requestEntityChunkSize = c.getIntBytes("request-entity-chunk-size"), incomingConnectionLevelBufferSize = c.getIntBytes("incoming-connection-level-buffer-size"), incomingStreamLevelBufferSize = c.getIntBytes("incoming-stream-level-buffer-size"), @@ -205,6 +223,20 @@ trait Http2ClientSettings extends javadsl.settings.Http2ClientSettings with Http def maxConcurrentStreams: Int override def withMaxConcurrentStreams(newValue: Int): Http2ClientSettings = copy(maxConcurrentStreams = newValue) + /** + * The maximum size of a decoded header list that this endpoint is prepared to accept, in bytes. The value is + * advertised to the peer via SETTINGS_MAX_HEADER_LIST_SIZE and the same limit is applied to the accumulated + * header block fragments of a HEADERS frame and its CONTINUATION frames. + * + * @since 2.0.0 + */ + def maxHeaderListSize: Int + + /** + * @since 2.0.0 + */ + override def withMaxHeaderListSize(newValue: Int): Http2ClientSettings = copy(maxHeaderListSize = newValue) + def outgoingControlFrameBufferSize: Int override def withOutgoingControlFrameBufferSize(newValue: Int): Http2ClientSettings = copy(outgoingControlFrameBufferSize = newValue) @@ -244,6 +276,7 @@ object Http2ClientSettings extends SettingsCompanion[Http2ClientSettings] { private[http] case class Http2ClientSettingsImpl( maxConcurrentStreams: Int, + maxHeaderListSize: Int, requestEntityChunkSize: Int, incomingConnectionLevelBufferSize: Int, incomingStreamLevelBufferSize: Int, @@ -258,6 +291,7 @@ object Http2ClientSettings extends SettingsCompanion[Http2ClientSettings] { internalSettings: Option[Http2InternalClientSettings]) extends Http2ClientSettings with javadsl.settings.Http2ClientSettings { require(maxConcurrentStreams >= 0, "max-concurrent-streams must be >= 0") + require(maxHeaderListSize > 0, "max-header-list-size must be > 0") require(requestEntityChunkSize > 0, "request-entity-chunk-size must be > 0") require(incomingConnectionLevelBufferSize > 0, "incoming-connection-level-buffer-size must be > 0") require(incomingStreamLevelBufferSize > 0, "incoming-stream-level-buffer-size must be > 0") @@ -272,6 +306,7 @@ object Http2ClientSettings extends SettingsCompanion[Http2ClientSettings] { extends pekko.http.impl.util.SettingsCompanionImpl[Http2ClientSettingsImpl]("pekko.http.client.http2") { def fromSubConfig(root: Config, c: Config): Http2ClientSettingsImpl = Http2ClientSettingsImpl( maxConcurrentStreams = c.getInt("max-concurrent-streams"), + maxHeaderListSize = c.getIntBytes("max-header-list-size"), requestEntityChunkSize = c.getIntBytes("request-entity-chunk-size"), incomingConnectionLevelBufferSize = c.getIntBytes("incoming-connection-level-buffer-size"), incomingStreamLevelBufferSize = c.getIntBytes("incoming-stream-level-buffer-size"), diff --git a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ServerSpec.scala b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ServerSpec.scala index cd3f459be1..95892435e0 100644 --- a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ServerSpec.scala +++ b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ServerSpec.scala @@ -17,6 +17,7 @@ import org.apache.pekko import pekko.NotUsed import pekko.http.impl.engine.http2.FrameEvent._ import pekko.http.impl.engine.http2.Http2Protocol.{ ErrorCode, Flags, FrameType, SettingIdentifier } +import pekko.http.impl.engine.http2.framing.FrameRenderer import pekko.http.impl.engine.server.{ HttpAttributes, ServerTerminator } import pekko.http.impl.engine.ws.ByteStringSinkProbe import pekko.http.scaladsl.client.RequestBuilding.Get @@ -191,6 +192,74 @@ class Http2ServerSpec extends Http2SpecWithMaterializer(""" headerPayload shouldBe HPackSpecExamples.C61FirstResponseWithHuffman }) + "reject an unfinished header block that grows beyond max-header-list-size".inAssertAllStagesStopped( + new TestSetup with RequestResponseProbes { + override def settings: ServerSettings = super.settings.mapHttp2Settings(_.withMaxHeaderListSize(4096)) + + val headerBlock = HPackSpecExamples.C41FirstRequestWithHuffman + network.sendHEADERS(1, endStream = true, endHeaders = false, headerBlock) + + // the peer never sets END_HEADERS, so neither the HPACK decoder nor request dispatch ever run + val fragment = ByteString(new Array[Byte](1024)) + (1 to 5).foreach(_ => network.sendCONTINUATION(1, endHeaders = false, fragment)) + + user.requestIn.ensureSubscription() + user.requestIn.expectNoMessage(100.millis) + + val (_, errorCode) = network.expectGOAWAY() + errorCode should ===(ErrorCode.ENHANCE_YOUR_CALM) + }) + "reject an unfinished header block made up of empty CONTINUATION frames".inAssertAllStagesStopped( + new TestSetup with RequestResponseProbes { + override def settings: ServerSettings = super.settings.mapHttp2Settings(_.withMaxHeaderListSize(256)) + + val headerBlock = HPackSpecExamples.C41FirstRequestWithHuffman + network.sendHEADERS(1, endStream = true, endHeaders = false, headerBlock) + + // empty fragments don't grow the header block but are accounted with their frame header size, so their + // number is bounded as well (sent in one go because the connection is failed in between) + network.sendBytes((1 to 64).map(_ => + FrameRenderer.render(ContinuationFrame(1, endHeaders = false, ByteString.empty))).reduce(_ ++ _)) + + user.requestIn.ensureSubscription() + user.requestIn.expectNoMessage(100.millis) + + val (_, errorCode) = network.expectGOAWAY() + errorCode should ===(ErrorCode.ENHANCE_YOUR_CALM) + }) + "reject a header block that decodes to more than max-header-list-size".inAssertAllStagesStopped( + new TestSetup with RequestResponseProbes { + override def settings: ServerSettings = super.settings.mapHttp2Settings(_.withMaxHeaderListSize(1024)) + + val request = HttpRequest( + uri = "http://www.example.com/", + headers = RawHeader("big-header", "x" * 2000) :: Nil) + network.sendHEADERS(1, endStream = true, endHeaders = true, network.encodeRequestHeaders(request)) + + user.requestIn.ensureSubscription() + user.requestIn.expectNoMessage(100.millis) + + val (_, errorCode) = network.expectGOAWAY() + errorCode should ===(ErrorCode.ENHANCE_YOUR_CALM) + }) + "accept a header block that stays within max-header-list-size".inAssertAllStagesStopped( + new TestSetup with RequestResponseProbes { + override def settings: ServerSettings = super.settings.mapHttp2Settings(_.withMaxHeaderListSize(1024)) + + val request = + HttpRequest(uri = "http://www.example.com/", headers = RawHeader("small-header", "x" * 100) :: Nil) + network.sendHEADERS(1, endStream = true, endHeaders = true, network.encodeRequestHeaders(request)) + + user.expectRequest().headers should contain(RawHeader("small-header", "x" * 100)) + }) + + "advertise SETTINGS_MAX_HEADER_LIST_SIZE to the peer" in + new TestSetupWithoutHandshake with RequestResponseProbes { + network.sendBytes(Http2Protocol.ClientConnectionPreface) + network.expectSETTINGS().settings should contain( + Setting(SettingIdentifier.SETTINGS_MAX_HEADER_LIST_SIZE, settings.http2Settings.maxHeaderListSize)) + } + "fail if Http2StreamIdHeader missing" in pending "automatically add `Date` header" in pending diff --git a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/RequestParsingSpec.scala b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/RequestParsingSpec.scala index b05334c5db..358f16ef1f 100644 --- a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/RequestParsingSpec.scala +++ b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/RequestParsingSpec.scala @@ -53,20 +53,19 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer with Inside with Insp val parseRequest: Http2SubStream => HttpRequest = RequestParsing.parseRequest(headerParser, serverSettings, attributes) - try Source.single(frame) - .via(new HeaderDecompression(headerParser, parserSettings)) - .map { // emulate demux - case headers: ParsedHeadersFrame => - Http2SubStream( - initialHeaders = headers, - trailingHeaders = OptionVal.None, - data = Right(data), - correlationAttributes = Map.empty) - } - .map(parseRequest) - .runWith(Sink.head) - .futureValue - catch { case ex: Throwable => throw ex.getCause } // unpack futureValue exceptions + Source.single(frame) + .via(new HeaderDecompression(headerParser, parserSettings, serverSettings.http2Settings.maxHeaderListSize)) + .map { // emulate demux + case headers: ParsedHeadersFrame => + Http2SubStream( + initialHeaders = headers, + trailingHeaders = OptionVal.None, + data = Right(data), + correlationAttributes = Map.empty) + } + .map(parseRequest) + .runWith(Sink.head) + .futureValue } def shouldThrowMalformedRequest[T](block: => T): Exception = { From 4b944e546c9876d43e1e9dbbefe7e07a70daa1ef Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Fri, 28 Aug 2026 21:25:02 +0100 Subject: [PATCH 2/4] Create http2-max-header-list-size.excludes --- .../http2-max-header-list-size.excludes | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 http-core/src/main/mima-filters/1.4.x.backwards.excludes/http2-max-header-list-size.excludes diff --git a/http-core/src/main/mima-filters/1.4.x.backwards.excludes/http2-max-header-list-size.excludes b/http-core/src/main/mima-filters/1.4.x.backwards.excludes/http2-max-header-list-size.excludes new file mode 100644 index 0000000000..b069604f79 --- /dev/null +++ b/http-core/src/main/mima-filters/1.4.x.backwards.excludes/http2-max-header-list-size.excludes @@ -0,0 +1,24 @@ +# 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. + +# new max-header-list-size setting for HTTP/2 (1.4.1) +ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.http.javadsl.settings.Http2ClientSettings.maxHeaderListSize") +ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.http.javadsl.settings.Http2ClientSettings.withMaxHeaderListSize") +ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.http.javadsl.settings.Http2ServerSettings.getMaxHeaderListSize") +ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.http.javadsl.settings.Http2ServerSettings.withMaxHeaderListSize") +ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.http.scaladsl.settings.Http2ClientSettings.maxHeaderListSize") +ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.http.scaladsl.settings.Http2ServerSettings.maxHeaderListSize") From 46cd6dd3a09ea705d724b50373ef6d230be4fd56 Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Fri, 28 Aug 2026 21:33:39 +0100 Subject: [PATCH 3/4] docs: the max-header-list-size settings ship in 1.4.1, not 2.0.0 Co-Authored-By: Claude Opus 5 (1M context) --- .../pekko/http/javadsl/settings/Http2ClientSettings.scala | 4 ++-- .../pekko/http/javadsl/settings/Http2ServerSettings.scala | 4 ++-- .../http/scaladsl/settings/Http2ServerSettings.scala | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/http-core/src/main/scala/org/apache/pekko/http/javadsl/settings/Http2ClientSettings.scala b/http-core/src/main/scala/org/apache/pekko/http/javadsl/settings/Http2ClientSettings.scala index bfcf87be7d..7f7428ceaf 100644 --- a/http-core/src/main/scala/org/apache/pekko/http/javadsl/settings/Http2ClientSettings.scala +++ b/http-core/src/main/scala/org/apache/pekko/http/javadsl/settings/Http2ClientSettings.scala @@ -39,12 +39,12 @@ trait Http2ClientSettings { self: scaladsl.settings.Http2ClientSettings.Http2Cli * advertised to the peer via SETTINGS_MAX_HEADER_LIST_SIZE and the same limit is applied to the accumulated * header block fragments of a HEADERS frame and its CONTINUATION frames. * - * @since 2.0.0 + * @since 1.4.1 */ def maxHeaderListSize: Int /** - * @since 2.0.0 + * @since 1.4.1 */ def withMaxHeaderListSize(newValue: Int): Http2ClientSettings = copy(maxHeaderListSize = newValue) diff --git a/http-core/src/main/scala/org/apache/pekko/http/javadsl/settings/Http2ServerSettings.scala b/http-core/src/main/scala/org/apache/pekko/http/javadsl/settings/Http2ServerSettings.scala index b7c026daa8..0ec10020c3 100644 --- a/http-core/src/main/scala/org/apache/pekko/http/javadsl/settings/Http2ServerSettings.scala +++ b/http-core/src/main/scala/org/apache/pekko/http/javadsl/settings/Http2ServerSettings.scala @@ -47,12 +47,12 @@ trait Http2ServerSettings { * advertised to the peer via SETTINGS_MAX_HEADER_LIST_SIZE and the same limit is applied to the accumulated * header block fragments of a HEADERS frame and its CONTINUATION frames. * - * @since 2.0.0 + * @since 1.4.1 */ def getMaxHeaderListSize: Int = maxHeaderListSize /** - * @since 2.0.0 + * @since 1.4.1 */ def withMaxHeaderListSize(newValue: Int): Http2ServerSettings diff --git a/http-core/src/main/scala/org/apache/pekko/http/scaladsl/settings/Http2ServerSettings.scala b/http-core/src/main/scala/org/apache/pekko/http/scaladsl/settings/Http2ServerSettings.scala index 40a32192b7..8bf3a17906 100644 --- a/http-core/src/main/scala/org/apache/pekko/http/scaladsl/settings/Http2ServerSettings.scala +++ b/http-core/src/main/scala/org/apache/pekko/http/scaladsl/settings/Http2ServerSettings.scala @@ -96,12 +96,12 @@ trait Http2ServerSettings extends javadsl.settings.Http2ServerSettings with Http * advertised to the peer via SETTINGS_MAX_HEADER_LIST_SIZE and the same limit is applied to the accumulated * header block fragments of a HEADERS frame and its CONTINUATION frames. * - * @since 2.0.0 + * @since 1.4.1 */ def maxHeaderListSize: Int /** - * @since 2.0.0 + * @since 1.4.1 */ override def withMaxHeaderListSize(newValue: Int): Http2ServerSettings = copy(maxHeaderListSize = newValue) @@ -228,12 +228,12 @@ trait Http2ClientSettings extends javadsl.settings.Http2ClientSettings with Http * advertised to the peer via SETTINGS_MAX_HEADER_LIST_SIZE and the same limit is applied to the accumulated * header block fragments of a HEADERS frame and its CONTINUATION frames. * - * @since 2.0.0 + * @since 1.4.1 */ def maxHeaderListSize: Int /** - * @since 2.0.0 + * @since 1.4.1 */ override def withMaxHeaderListSize(newValue: Int): Http2ClientSettings = copy(maxHeaderListSize = newValue) From ff3c97b4e19b233ed1815002c820e646fe2245f3 Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Fri, 28 Aug 2026 21:46:43 +0100 Subject: [PATCH 4/4] fix: drop main-only changes that the backport pulled in `ParsedHeadersFrame` has no error-info field on 1.4.x and the parsing exception is not routed through it, so the header decompression stage keeps 1.4.x's four-argument frame and its IOException-only handling, along with `ByteStringInputStream`. Only the header list size checks are new. `RequestParsingSpec` keeps unpacking `futureValue` exceptions, since a malformed request still fails the stream here. Co-Authored-By: Claude Opus 5 (1M context) --- .../http2/hpack/HeaderDecompression.scala | 10 ++----- .../engine/http2/RequestParsingSpec.scala | 27 ++++++++++--------- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderDecompression.scala b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderDecompression.scala index 4f488614de..fc7aafb36c 100644 --- a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderDecompression.scala +++ b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderDecompression.scala @@ -102,24 +102,18 @@ private[http2] final class HeaderDecompression(masterHeaderParser: HttpHeaderPar } } } - val stream = payload.compact.asInputStream try { - decoder.decode(stream, Receiver) // only compact ByteString supports InputStream with mark/reset + decoder.decode(ByteStringInputStream(payload), Receiver) // the decoder stops emitting headers as soon as the limit is exceeded and reports that here val truncated = decoder.endHeaderBlock() if (truncated) headerListSizeExceeded(streamId) - else push(eventsOut, ParsedHeadersFrame(streamId, endStream, headers.result(), prioInfo, None)) + else push(eventsOut, ParsedHeadersFrame(streamId, endStream, headers.result(), prioInfo)) } catch { - case ex: ParsingException => - // push details further and let RequestErrorFlow handle responding with bad request - push(eventsOut, ParsedHeadersFrame(streamId, endStream, Seq.empty, prioInfo, Some(ex.info))) case _: IOException => // this is signalled by the decoder when it failed, we want to react to this by rendering a GOAWAY frame fail(eventsOut, new Http2Compliance.Http2ProtocolException(ErrorCode.COMPRESSION_ERROR, "Decompression failed.")) - } finally { - stream.close() } } diff --git a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/RequestParsingSpec.scala b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/RequestParsingSpec.scala index 358f16ef1f..57579b5004 100644 --- a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/RequestParsingSpec.scala +++ b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/RequestParsingSpec.scala @@ -53,19 +53,20 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer with Inside with Insp val parseRequest: Http2SubStream => HttpRequest = RequestParsing.parseRequest(headerParser, serverSettings, attributes) - Source.single(frame) - .via(new HeaderDecompression(headerParser, parserSettings, serverSettings.http2Settings.maxHeaderListSize)) - .map { // emulate demux - case headers: ParsedHeadersFrame => - Http2SubStream( - initialHeaders = headers, - trailingHeaders = OptionVal.None, - data = Right(data), - correlationAttributes = Map.empty) - } - .map(parseRequest) - .runWith(Sink.head) - .futureValue + try Source.single(frame) + .via(new HeaderDecompression(headerParser, parserSettings, serverSettings.http2Settings.maxHeaderListSize)) + .map { // emulate demux + case headers: ParsedHeadersFrame => + Http2SubStream( + initialHeaders = headers, + trailingHeaders = OptionVal.None, + data = Right(data), + correlationAttributes = Map.empty) + } + .map(parseRequest) + .runWith(Sink.head) + .futureValue + catch { case ex: Throwable => throw ex.getCause } // unpack futureValue exceptions } def shouldThrowMalformedRequest[T](block: => T): Exception = {