diff --git a/http-core/src/main/mima-filters/2.0.x.backwards.excludes/max-frame-size.excludes b/http-core/src/main/mima-filters/2.0.x.backwards.excludes/max-frame-size.excludes new file mode 100644 index 000000000..1f9d0a43e --- /dev/null +++ b/http-core/src/main/mima-filters/2.0.x.backwards.excludes/max-frame-size.excludes @@ -0,0 +1,33 @@ +# 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-frame-size setting +ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.http.javadsl.settings.Http2ClientSettings.maxFrameSize") +ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.http.javadsl.settings.Http2ClientSettings.withMaxFrameSize") +ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.http.javadsl.settings.Http2ServerSettings.getMaxFrameSize") +ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.http.javadsl.settings.Http2ServerSettings.withMaxFrameSize") +ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.http.scaladsl.settings.Http2ClientSettings.maxFrameSize") +ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.http.scaladsl.settings.Http2ServerSettings.maxFrameSize") +ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.http.scaladsl.settings.Http2CommonSettings.maxFrameSize") + +# internal frame parser now takes the limit as a constructor parameter +ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.http.impl.engine.http2.framing.Http2FrameParsing.this") + +# the new field shifts the positions of the private settings case class parameters after it +ProblemFilters.exclude[IncompatibleResultTypeProblem]("org.apache.pekko.http.scaladsl.settings.Http2ClientSettings#Http2ClientSettingsImpl.copy$default$8") +ProblemFilters.exclude[IncompatibleResultTypeProblem]("org.apache.pekko.http.scaladsl.settings.Http2ClientSettings#Http2ClientSettingsImpl.copy$default$11") +ProblemFilters.exclude[IncompatibleResultTypeProblem]("org.apache.pekko.http.scaladsl.settings.Http2ServerSettings#Http2ServerSettingsImpl.copy$default$9") diff --git a/http-core/src/main/resources/reference.conf b/http-core/src/main/resources/reference.conf index a61326119..8ca31252f 100644 --- a/http-core/src/main/resources/reference.conf +++ b/http-core/src/main/resources/reference.conf @@ -263,6 +263,20 @@ pekko.http { # effective limit for a well-behaved peer is somewhat stricter than the configured value. max-header-list-size = 64 KiB + # The largest frame payload this endpoint accepts, in bytes. A larger incoming frame is rejected with a + # FRAME_SIZE_ERROR on its frame header, before the payload is buffered, so this bounds how much a single frame + # can make this endpoint hold at once. The length field of a frame header allows up to 16 MiB - 1. + # + # This is a limit, not an invitation: no larger SETTINGS_MAX_FRAME_SIZE is advertised, so a peer that follows + # the spec keeps to the 16 KiB default of RFC 9113, section 4.2 and the extra room here is only leniency for + # peers that do not. Advertising a value far above the initial 64 KiB flow-control window would invite frames + # that cannot be received without breaking flow control anyway. + # + # RFC 9113, section 4.2 constrains this to be between 16 KiB and 16 MiB - 1. The amount of request data + # buffered overall is bounded separately by the incoming-connection-level-buffer-size and + # incoming-stream-level-buffer-size settings below. + max-frame-size = 512kB + # 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 @@ -512,6 +526,20 @@ pekko.http { # effective limit for a well-behaved peer is somewhat stricter than the configured value. max-header-list-size = 64 KiB + # The largest frame payload this endpoint accepts, in bytes. A larger incoming frame is rejected with a + # FRAME_SIZE_ERROR on its frame header, before the payload is buffered, so this bounds how much a single frame + # can make this endpoint hold at once. The length field of a frame header allows up to 16 MiB - 1. + # + # This is a limit, not an invitation: no larger SETTINGS_MAX_FRAME_SIZE is advertised, so a peer that follows + # the spec keeps to the 16 KiB default of RFC 9113, section 4.2 and the extra room here is only leniency for + # peers that do not. Advertising a value far above the initial 64 KiB flow-control window would invite frames + # that cannot be received without breaking flow control anyway. + # + # RFC 9113, section 4.2 constrains this to be between 16 KiB and 16 MiB - 1. The amount of request data + # buffered overall is bounded separately by the incoming-connection-level-buffer-size and + # incoming-stream-level-buffer-size settings below. + max-frame-size = 512kB + # 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 aff6e14ae..676a131cf 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 @@ -144,8 +144,9 @@ private[http] object Http2Blueprint { val frameTypesForThrottle = getFrameTypesForThrottle(settings.http2Settings) val flowWithPossibleThrottle = if (frameTypesForThrottle.nonEmpty) { - initialFlow atop rapidResetMitigation(settings.http2Settings, frameTypesForThrottle) atopKeepLeft framing(log) - } else initialFlow atop framing(log) + initialFlow atop rapidResetMitigation(settings.http2Settings, frameTypesForThrottle) atopKeepLeft framing(log, + settings.http2Settings.maxFrameSize) + } else initialFlow atop framing(log, settings.http2Settings.maxFrameSize) flowWithPossibleThrottle atop errorHandling(log) atop @@ -166,7 +167,7 @@ private[http] object Http2Blueprint { clientDemux(settings.http2Settings, masterHttpHeaderParser)).atop( FrameLogger.logFramesIfEnabled(settings.http2Settings.logFrames)).atop( // enable for debugging hpackCoding(masterHttpHeaderParser, settings.parserSettings, settings.http2Settings.maxHeaderListSize)).atop( - framingClient(log)).atop( + framingClient(log, settings.http2Settings.maxFrameSize)).atop( errorHandling(log)).atop( idleTimeoutIfConfigured(settings.idleTimeout)) } @@ -208,15 +209,17 @@ private[http] object Http2Blueprint { }, Flow[ByteString]) - def framing(log: LoggingAdapter): BidiFlow[FrameEvent, ByteString, ByteString, FrameEvent, NotUsed] = + def framing(log: LoggingAdapter, maxFrameSize: Int) + : BidiFlow[FrameEvent, ByteString, ByteString, FrameEvent, NotUsed] = BidiFlow.fromFlows( Flow[FrameEvent].map(FrameRenderer.render), - Flow[ByteString].via(new Http2FrameParsing(shouldReadPreface = true, log))) + Flow[ByteString].via(new Http2FrameParsing(shouldReadPreface = true, log, maxFrameSize))) - def framingClient(log: LoggingAdapter): BidiFlow[FrameEvent, ByteString, ByteString, FrameEvent, NotUsed] = + def framingClient(log: LoggingAdapter, + maxFrameSize: Int): BidiFlow[FrameEvent, ByteString, ByteString, FrameEvent, NotUsed] = BidiFlow.fromFlows( Flow[FrameEvent].map(FrameRenderer.render).prepend(Source.single(Http2Protocol.ClientConnectionPreface)), - Flow[ByteString].via(new Http2FrameParsing(shouldReadPreface = false, log))) + Flow[ByteString].via(new Http2FrameParsing(shouldReadPreface = false, log, maxFrameSize))) private def rapidResetMitigation(settings: Http2ServerSettings, frameTypesForThrottle: Set[String]): BidiFlow[FrameEvent, FrameEvent, FrameEvent, FrameEvent, NotUsed] = { diff --git a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/framing/Http2FrameParsing.scala b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/framing/Http2FrameParsing.scala index d236e06c7..42944a40b 100644 --- a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/framing/Http2FrameParsing.scala +++ b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/framing/Http2FrameParsing.scala @@ -165,7 +165,8 @@ private[http] object Http2FrameParsing { /** INTERNAL API */ @InternalApi private[http2] class Http2FrameParsing( - shouldReadPreface: Boolean, log: LoggingAdapter) extends ByteStringParser[FrameEvent] { + shouldReadPreface: Boolean, log: LoggingAdapter, + maxFrameSize: Int = Http2Protocol.InitialMaxFrameSize) extends ByteStringParser[FrameEvent] { import ByteStringParser._ import Http2FrameParsing._ @@ -190,6 +191,12 @@ private[http2] class Http2FrameParsing( object ReadFrame extends Step { override def parse(reader: ByteReader): ParseResult[FrameEvent] = { val length = reader.readShortBE() << 8 | reader.readByte() + // Reject before `reader.take(length)` below buffers the payload: the length field allows up to 16 MiB, so + // without this a peer could make the parser hold that much for a single frame. FRAME_SIZE_ERROR is what + // RFC 9113, section 4.2 asks for; we accept up to `maxFrameSize` rather than the 16 KiB default the peer + // is expected to keep to, so this only rejects a peer that already exceeds what it was told. + if (length > maxFrameSize) + throw new Http2Compliance.IllegalHttp2FrameSize(length, s"exceeds the maximum frame size of $maxFrameSize") val tpe = reader.readByte() val flags = new ByteFlag(reader.readByte()) // RFC 9113 5.1.1: the high bit of the stream identifier is reserved and MUST be ignored when receiving. 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 bfcf87be7..2dbd2b299 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 @@ -48,6 +48,21 @@ trait Http2ClientSettings { self: scaladsl.settings.Http2ClientSettings.Http2Cli */ def withMaxHeaderListSize(newValue: Int): Http2ClientSettings = copy(maxHeaderListSize = newValue) + /** + * The largest frame payload this endpoint accepts, in bytes. A larger incoming frame is rejected with a + * FRAME_SIZE_ERROR on its frame header, before the payload is buffered. No larger SETTINGS_MAX_FRAME_SIZE is + * advertised, so a peer that follows the spec keeps to the 16 KiB default and the extra room is leniency for peers + * that do not. RFC 9113, section 4.2 constrains it to be between 16 KiB and 16 MiB - 1. + * + * @since 2.0.0 + */ + def maxFrameSize: Int + + /** + * @since 2.0.0 + */ + def withMaxFrameSize(newValue: Int): Http2ClientSettings = copy(maxFrameSize = 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 1c9165e16..dd85fe4ce 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 @@ -56,6 +56,21 @@ trait Http2ServerSettings { */ def withMaxHeaderListSize(newValue: Int): Http2ServerSettings + /** + * The largest frame payload this endpoint accepts, in bytes. A larger incoming frame is rejected with a + * FRAME_SIZE_ERROR on its frame header, before the payload is buffered. No larger SETTINGS_MAX_FRAME_SIZE is + * advertised, so a peer that follows the spec keeps to the 16 KiB default and the extra room is leniency for peers + * that do not. RFC 9113, section 4.2 constrains it to be between 16 KiB and 16 MiB - 1. + * + * @since 2.0.0 + */ + def getMaxFrameSize: Int = maxFrameSize + + /** + * @since 2.0.0 + */ + def withMaxFrameSize(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 19847648f..c6787c779 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 @@ -17,6 +17,7 @@ import org.apache.pekko import pekko.annotation.ApiMayChange import pekko.annotation.DoNotInherit import pekko.annotation.InternalApi +import pekko.http.impl.engine.http2.Http2Protocol import pekko.http.impl.util._ import pekko.http.javadsl import com.typesafe.config.Config @@ -42,6 +43,7 @@ private[http] trait Http2CommonSettings { def logFrames: Boolean def maxConcurrentStreams: Int def maxHeaderListSize: Int + def maxFrameSize: Int def outgoingControlFrameBufferSize: Int def pingInterval: FiniteDuration @@ -105,6 +107,21 @@ trait Http2ServerSettings extends javadsl.settings.Http2ServerSettings with Http */ override def withMaxHeaderListSize(newValue: Int): Http2ServerSettings = copy(maxHeaderListSize = newValue) + /** + * The largest frame payload this endpoint accepts, in bytes. A larger incoming frame is rejected with a + * FRAME_SIZE_ERROR on its frame header, before the payload is buffered. No larger SETTINGS_MAX_FRAME_SIZE is + * advertised, so a peer that follows the spec keeps to the 16 KiB default and the extra room is leniency for peers + * that do not. RFC 9113, section 4.2 constrains it to be between 16 KiB and 16 MiB - 1. + * + * @since 2.0.0 + */ + def maxFrameSize: Int + + /** + * @since 2.0.0 + */ + override def withMaxFrameSize(newValue: Int): Http2ServerSettings = copy(maxFrameSize = newValue) + def outgoingControlFrameBufferSize: Int override def withOutgoingControlFrameBufferSize(newValue: Int): Http2ServerSettings = copy(outgoingControlFrameBufferSize = newValue) @@ -145,6 +162,7 @@ object Http2ServerSettings extends SettingsCompanion[Http2ServerSettings] { private[http] case class Http2ServerSettingsImpl( maxConcurrentStreams: Int, maxHeaderListSize: Int, + maxFrameSize: Int, requestEntityChunkSize: Int, incomingConnectionLevelBufferSize: Int, incomingStreamLevelBufferSize: Int, @@ -161,6 +179,9 @@ object Http2ServerSettings extends SettingsCompanion[Http2ServerSettings] { extends Http2ServerSettings { require(maxConcurrentStreams >= 0, "max-concurrent-streams must be >= 0") require(maxHeaderListSize > 0, "max-header-list-size must be > 0") + // RFC 9113, section 4.2: SETTINGS_MAX_FRAME_SIZE must be within these bounds + require(maxFrameSize >= Http2Protocol.MinFrameSize && maxFrameSize <= Http2Protocol.MaxFrameSize, + s"max-frame-size must be between ${Http2Protocol.MinFrameSize} and ${Http2Protocol.MaxFrameSize}") 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") @@ -179,6 +200,7 @@ object Http2ServerSettings extends SettingsCompanion[Http2ServerSettings] { def fromSubConfig(root: Config, c: Config): Http2ServerSettingsImpl = Http2ServerSettingsImpl( maxConcurrentStreams = c.getInt("max-concurrent-streams"), maxHeaderListSize = c.getIntBytes("max-header-list-size"), + maxFrameSize = c.getIntBytes("max-frame-size"), requestEntityChunkSize = c.getIntBytes("request-entity-chunk-size"), incomingConnectionLevelBufferSize = c.getIntBytes("incoming-connection-level-buffer-size"), incomingStreamLevelBufferSize = c.getIntBytes("incoming-stream-level-buffer-size"), @@ -237,6 +259,21 @@ trait Http2ClientSettings extends javadsl.settings.Http2ClientSettings with Http */ override def withMaxHeaderListSize(newValue: Int): Http2ClientSettings = copy(maxHeaderListSize = newValue) + /** + * The largest frame payload this endpoint accepts, in bytes. A larger incoming frame is rejected with a + * FRAME_SIZE_ERROR on its frame header, before the payload is buffered. No larger SETTINGS_MAX_FRAME_SIZE is + * advertised, so a peer that follows the spec keeps to the 16 KiB default and the extra room is leniency for peers + * that do not. RFC 9113, section 4.2 constrains it to be between 16 KiB and 16 MiB - 1. + * + * @since 2.0.0 + */ + def maxFrameSize: Int + + /** + * @since 2.0.0 + */ + override def withMaxFrameSize(newValue: Int): Http2ClientSettings = copy(maxFrameSize = newValue) + def outgoingControlFrameBufferSize: Int override def withOutgoingControlFrameBufferSize(newValue: Int): Http2ClientSettings = copy(outgoingControlFrameBufferSize = newValue) @@ -277,6 +314,7 @@ object Http2ClientSettings extends SettingsCompanion[Http2ClientSettings] { private[http] case class Http2ClientSettingsImpl( maxConcurrentStreams: Int, maxHeaderListSize: Int, + maxFrameSize: Int, requestEntityChunkSize: Int, incomingConnectionLevelBufferSize: Int, incomingStreamLevelBufferSize: Int, @@ -292,6 +330,9 @@ object Http2ClientSettings extends SettingsCompanion[Http2ClientSettings] { 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") + // RFC 9113, section 4.2: SETTINGS_MAX_FRAME_SIZE must be within these bounds + require(maxFrameSize >= Http2Protocol.MinFrameSize && maxFrameSize <= Http2Protocol.MaxFrameSize, + s"max-frame-size must be between ${Http2Protocol.MinFrameSize} and ${Http2Protocol.MaxFrameSize}") 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") @@ -307,6 +348,7 @@ object Http2ClientSettings extends SettingsCompanion[Http2ClientSettings] { def fromSubConfig(root: Config, c: Config): Http2ClientSettingsImpl = Http2ClientSettingsImpl( maxConcurrentStreams = c.getInt("max-concurrent-streams"), maxHeaderListSize = c.getIntBytes("max-header-list-size"), + maxFrameSize = c.getIntBytes("max-frame-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 8bbab8a0d..2319ace06 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 @@ -288,6 +288,18 @@ class Http2ServerSpec extends Http2SpecWithMaterializer(""" user.expectRequest().headers should contain(RawHeader("small-header", "x" * 100)) }) + "reject a frame larger than max-frame-size".inAssertAllStagesStopped( + new TestSetup with RequestResponseProbes { + override def settings: ServerSettings = super.settings.mapHttp2Settings(_.withMaxFrameSize(16384)) + + // the length field allows up to 16 MiB, so an oversized frame has to be rejected on the frame header + // rather than accepted and buffered + network.sendDATA(1, endStream = true, ByteString(new Array[Byte](16385))) + + val (_, errorCode) = network.expectGOAWAY() + errorCode should ===(ErrorCode.FRAME_SIZE_ERROR) + }) + "advertise SETTINGS_MAX_HEADER_LIST_SIZE to the peer" in new TestSetupWithoutHandshake with RequestResponseProbes { network.sendBytes(Http2Protocol.ClientConnectionPreface) @@ -793,6 +805,10 @@ class Http2ServerSpec extends Http2SpecWithMaterializer(""" }) "fail if more data is received than stream-level window allows".inAssertAllStagesStopped( new WaitingForRequestData { + // the single frame below is deliberately bigger than the stream-level buffer, and so also bigger than the + // default max-frame-size; raise that limit so the frame reaches the flow-control check this test is about + override def settings: ServerSettings = super.settings.mapHttp2Settings(_.withMaxFrameSize(1024 * 1024)) + // trigger a connection-level WINDOW_UPDATE network.sendDATA(TheStreamId, endStream = false, ByteString("0000")) entityDataIn.expectUtf8EncodedString("0000") @@ -809,8 +825,10 @@ class Http2ServerSpec extends Http2SpecWithMaterializer(""" val ConnectionBufferSize = 700000 "release connection-level flow control accounting when a stream-level window is exceeded" .inAssertAllStagesStopped(new WaitingForRequestData { + // the oversized frame also exceeds the default max-frame-size; raise that limit so it reaches flow control override def settings: ServerSettings = - super.settings.mapHttp2Settings(_.withIncomingConnectionLevelBufferSize(ConnectionBufferSize)) + super.settings.mapHttp2Settings( + _.withIncomingConnectionLevelBufferSize(ConnectionBufferSize).withMaxFrameSize(1024 * 1024)) // get the request dispatched and both windows replenished to their configured sizes network.sendDATA(TheStreamId, endStream = false, ByteString("0000"))