From 22679c09a43224c85ea7ed9ef2f0cc1fed2b39ed Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Sat, 22 Aug 2026 21:59:13 +0100 Subject: [PATCH] http/2: bound incoming header blocks with max-header-list-size 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 --- .../http2-max-header-list-size.excludes | 24 +++++++ 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 | 41 +++++++++-- .../settings/Http2ClientSettings.scala | 14 ++++ .../settings/Http2ServerSettings.scala | 14 ++++ .../settings/Http2ServerSettings.scala | 35 ++++++++++ .../impl/engine/http2/Http2ServerSpec.scala | 69 +++++++++++++++++++ .../engine/http2/RequestParsingSpec.scala | 2 +- 10 files changed, 228 insertions(+), 12 deletions(-) create mode 100644 http-core/src/main/mima-filters/2.0.x.backwards.excludes/http2-max-header-list-size.excludes diff --git a/http-core/src/main/mima-filters/2.0.x.backwards.excludes/http2-max-header-list-size.excludes b/http-core/src/main/mima-filters/2.0.x.backwards.excludes/http2-max-header-list-size.excludes new file mode 100644 index 0000000000..461457482a --- /dev/null +++ b/http-core/src/main/mima-filters/2.0.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 +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") diff --git a/http-core/src/main/resources/reference.conf b/http-core/src/main/resources/reference.conf index 1c7019879b..0767de86bd 100644 --- a/http-core/src/main/resources/reference.conf +++ b/http-core/src/main/resources/reference.conf @@ -239,6 +239,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 @@ -473,6 +488,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 31454417f6..aa7de7bda2 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 df829c20bc..9e63961c9f 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 6d992e36b0..6aad6e0857 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 @@ -36,13 +36,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") @@ -51,8 +61,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) @@ -99,9 +109,11 @@ 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.endHeaderBlock() // TODO: do we have to check the result here? + // 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, None)) + if (truncated) headerListSizeExceeded(streamId) + else push(eventsOut, ParsedHeadersFrame(streamId, endStream, headers.result(), prioInfo, None)) } catch { case ex: ParsingException => // push details further and let RequestErrorFlow handle responding with bad request @@ -119,6 +131,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) @@ -132,14 +145,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 => @@ -147,6 +167,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 a0493aeb4e..1c9165e168 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 a239f0d5fc..19847648fd 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 573a963351..1d478726da 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 @@ -23,6 +23,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 @@ -188,6 +189,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 9c692d5775..0d4d2f23b7 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 @@ -59,7 +59,7 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer with Inside with Insp attributes) Source.single(frame) - .via(new HeaderDecompression(headerParser, parserSettings)) + .via(new HeaderDecompression(headerParser, parserSettings, serverSettings.http2Settings.maxHeaderListSize)) .map { // emulate demux case headers: ParsedHeadersFrame => Http2SubStream(