Repository: http4s/http4s (scala)

Issue:
Ember server returns 500 on too many headers, doesn't say why
1. Run an ember server with defaults
2. Send it more than 40k of headers
3. Response is a `500` with `Content-Length: 0` and no mention of what's wrong in the logs

This is a bad request and should generate a 4xx error, perhaps a [431](https://datatracker.ietf.org/doc/html/rfc6585#page-4).

Pull request description:
- Adds a maxHeaderSizeErrorHandler property to EmberServerBuilder
- The default returns an empty 431 response when the default message length is exceeded.  Currently, we return a 500.

I don't love the proliferation of options, but I think this is in the original spirit of the errorHandler.  This needs a rethink in 1.0. 

Fixes #7395.

Patch:
diff --git a/ember-server/shared/src/main/scala/org/http4s/ember/server/EmberServerBuilder.scala b/ember-server/shared/src/main/scala/org/http4s/ember/server/EmberServerBuilder.scala
index 4587654f261..26783eb45df 100644
--- a/ember-server/shared/src/main/scala/org/http4s/ember/server/EmberServerBuilder.scala
+++ b/ember-server/shared/src/main/scala/org/http4s/ember/server/EmberServerBuilder.scala
@@ -29,6 +29,7 @@ import fs2.io.net.tls._
 import fs2.io.net.unixsocket.UnixSocketAddress
 import fs2.io.net.unixsocket.UnixSockets
 import org.http4s._
+import org.http4s.ember.core.EmberException
 import org.http4s.ember.server.internal.ServerHelpers
 import org.http4s.ember.server.internal.Shutdown
 import org.http4s.server.Ip4sServer
@@ -57,6 +58,7 @@ final class EmberServerBuilder[F[_]: Async: Network] private (
     private val unixSocketConfig: Option[(UnixSockets[F], UnixSocketAddress, Boolean, Boolean)],
     private val enableHttp2: Boolean,
     private val requestLineParseErrorHandler: Throwable => F[Response[F]],
+    private val maxHeaderSizeErrorHandler: EmberException.MessageTooLong => F[Response[F]],
 ) { self =>
 
   @deprecated("Use org.http4s.ember.server.EmberServerBuilder.maxConnections", "0.22.3")
@@ -83,6 +85,8 @@ final class EmberServerBuilder[F[_]: Async: Network] private (
         self.unixSocketConfig,
       enableHttp2: Boolean = self.enableHttp2,
       requestLineParseErrorHandler: Throwable => F[Response[F]] = self.requestLineParseErrorHandler,
+      maxHeaderSizeErrorHandler: EmberException.MessageTooLong => F[Response[F]] =
+        self.maxHeaderSizeErrorHandler,
   ): EmberServerBuilder[F] =
     new EmberServerBuilder[F](
       host = host,
@@ -104,6 +108,7 @@ final class EmberServerBuilder[F[_]: Async: Network] private (
       unixSocketConfig = unixSocketConfig,
       enableHttp2 = enableHttp2,
       requestLineParseErrorHandler = requestLineParseErrorHandler,
+      maxHeaderSizeErrorHandler = maxHeaderSizeErrorHandler,
     )
 
   def withHostOption(host: Option[Host]): EmberServerBuilder[F] = copy(host = host)
@@ -166,8 +171,22 @@ final class EmberServerBuilder[F[_]: Async: Network] private (
 
   def withReceiveBufferSize(receiveBufferSize: Int): EmberServerBuilder[F] =
     copy(receiveBufferSize = receiveBufferSize)
+
   def withMaxHeaderSize(maxHeaderSize: Int): EmberServerBuilder[F] =
     copy(maxHeaderSize = maxHeaderSize)
+
+  /** Customizes the error response when the request's header fields
+    * exceed `maxHeaderSize`.  The default behavior is to return an
+    * `431 Request Header Fields Too Large` response.
+    *
+    * @see [[https://www.rfc-editor.org/rfc/rfc9110.html#name-field-limits RFC 9110, Section 5.4]]
+    * @see [[https://www.rfc-editor.org/rfc/rfc6585.html#section-5 RFC 6585, Section 5]]
+    */
+  def withMaxHeaderSizeErrorHandler(
+      maxHeaderSizeErrorHandler: EmberException.MessageTooLong => F[Response[F]]
+  ): EmberServerBuilder[F] =
+    copy(maxHeaderSizeErrorHandler = maxHeaderSizeErrorHandler)
+
   def withRequestHeaderReceiveTimeout(
       requestHeaderReceiveTimeout: Duration
   ): EmberServerBuilder[F] =
@@ -240,6 +259,7 @@ final class EmberServerBuilder[F[_]: Async: Network] private (
               wsBuilder.webSocketKey,
               enableHttp2,
               requestLineParseErrorHandler,
+              maxHeaderSizeErrorHandler,
             )
             .compile
             .drain
@@ -267,6 +287,7 @@ final class EmberServerBuilder[F[_]: Async: Network] private (
             wsBuilder.webSocketKey,
             enableHttp2,
             requestLineParseErrorHandler,
+            maxHeaderSizeErrorHandler,
           )
           .compile
           .drain
@@ -303,6 +324,7 @@ object EmberServerBuilder extends EmberServerBuilderCompanionPlatform {
       unixSocketConfig = None,
       enableHttp2 = false,
       requestLineParseErrorHandler = Defaults.requestLineParseErrorHandler,
+      maxHeaderSizeErrorHandler = Defaults.maxHeaderSizeErrorHandler,
     )
 
   @deprecated("Use the overload which accepts a Network", "0.23.16")
@@ -340,6 +362,16 @@ object EmberServerBuilder extends EmberServerBuilderCompanionPlatform {
       ).pure[F]
     }
 
+    def maxHeaderSizeErrorHandler[F[_]: Applicative]
+        : EmberException.MessageTooLong => F[Response[F]] =
+      Function.const(
+        Response(
+          Status.RequestHeaderFieldsTooLarge,
+          HttpVersion.`HTTP/1.1`,
+          Headers(org.http4s.headers.`Content-Length`.zero),
+        ).pure[F]
+      )
+
     def onWriteFailure[F[_]: Applicative]
         : (Option[Request[F]], Response[F], Throwable) => F[Unit] = {
       case _: (Option[Request[F]], Response[F], Throwable) => Applicative[F].unit
diff --git a/ember-server/shared/src/main/scala/org/http4s/ember/server/internal/ServerHelpers.scala b/ember-server/shared/src/main/scala/org/http4s/ember/server/internal/ServerHelpers.scala
index 3633c5e947e..4cb454e2e94 100644
--- a/ember-server/shared/src/main/scala/org/http4s/ember/server/internal/ServerHelpers.scala
+++ b/ember-server/shared/src/main/scala/org/http4s/ember/server/internal/ServerHelpers.scala
@@ -77,6 +77,7 @@ private[server] object ServerHelpers extends ServerHelpersPlatform {
       webSocketKey: Key[WebSocketContext[F]],
       enableHttp2: Boolean,
       requestLineParseErrorHandler: Throwable => F[Response[F]],
+      maxHeaderSizeErrorHandler: EmberException.MessageTooLong => F[Response[F]],
   )(implicit F: Async[F]): Stream[F, Nothing] = {
     val server: Stream[F, Socket[F]] =
       Stream
@@ -104,6 +105,7 @@ private[server] object ServerHelpers extends ServerHelpersPlatform {
       webSocketKey,
       enableHttp2,
       requestLineParseErrorHandler,
+      maxHeaderSizeErrorHandler,
     )
   }
 
@@ -129,6 +131,7 @@ private[server] object ServerHelpers extends ServerHelpersPlatform {
       webSocketKey: Key[WebSocketContext[F]],
       enableHttp2: Boolean,
       requestLineParseErrorHandler: Throwable => F[Response[F]],
+      maxHeaderSizeErrorHandler: EmberException.MessageTooLong => F[Response[F]],
   ): Stream[F, Nothing] = {
     val server =
       // Our interface has an issue
@@ -161,6 +164,7 @@ private[server] object ServerHelpers extends ServerHelpersPlatform {
       webSocketKey,
       enableHttp2,
       requestLineParseErrorHandler,
+      maxHeaderSizeErrorHandler,
     )
   }
 
@@ -187,6 +191,7 @@ private[server] object ServerHelpers extends ServerHelpersPlatform {
       webSocketKey: Key[WebSocketContext[F]],
       enableHttp2: Boolean,
       requestLineParseErrorHandler: Throwable => F[Response[F]],
+      maxHeaderSizeErrorHandler: EmberException.MessageTooLong => F[Response[F]],
   ): Stream[F, Nothing] = {
     val streams: Stream[F, Stream[F, Nothing]] = server
       .interruptWhen(shutdown.signal.attempt)
@@ -231,6 +236,7 @@ private[server] object ServerHelpers extends ServerHelpersPlatform {
                   ByteVector.empty,
                   enableHttp2,
                   requestLineParseErrorHandler,
+                  maxHeaderSizeErrorHandler,
                 ).drain
               case (socket, None) => // Cleartext Protocol
                 enableHttp2 match {
@@ -254,6 +260,7 @@ private[server] object ServerHelpers extends ServerHelpersPlatform {
                           bv, // Pass read bytes we thought might be the prelude
                           enableHttp2,
                           requestLineParseErrorHandler,
+                          maxHeaderSizeErrorHandler,
                         ).drain
                       case Right(_) =>
                         Stream
@@ -284,6 +291,7 @@ private[server] object ServerHelpers extends ServerHelpersPlatform {
                       ByteVector.empty,
                       enableHttp2,
                       requestLineParseErrorHandler,
+                      maxHeaderSizeErrorHandler,
                     ).drain
                 }
             }
@@ -426,6 +434,7 @@ private[server] object ServerHelpers extends ServerHelpersPlatform {
       initialBuffer: ByteVector,
       enableHttp2: Boolean,
       requestLineParseErrorHandler: Throwable => F[Response[F]],
+      maxHeaderSizeErrorHandler: EmberException.MessageTooLong => F[Response[F]],
   ): Stream[F, Nothing] = {
     type State = (Array[Byte], Boolean)
     val finalApp = if (enableHttp2) H2Server.h2cUpgradeMiddleware(httpApp) else httpApp
@@ -527,6 +536,8 @@ private[server] object ServerHelpers extends ServerHelpersPlatform {
                 (err match {
                   case err: Parser.Request.ReqPrelude.ParsePreludeError =>
                     requestLineParseErrorHandler(err)
+                  case err: EmberException.MessageTooLong =>
+                    maxHeaderSizeErrorHandler(err)
                   case err =>
                     errorHandler(err)
                 }).handleError(_ => serverFailure.covary[F])


Test patch:
diff --git a/ember-server/shared/src/test/scala/org/http4s/ember/server/ConnectionSuite.scala b/ember-server/shared/src/test/scala/org/http4s/ember/server/ConnectionSuite.scala
index e3af91c5520..365a8f872cb 100644
--- a/ember-server/shared/src/test/scala/org/http4s/ember/server/ConnectionSuite.scala
+++ b/ember-server/shared/src/test/scala/org/http4s/ember/server/ConnectionSuite.scala
@@ -209,4 +209,40 @@ class ConnectionSuite extends Http4sSuite {
       resp <- client.responsePrelude
     } yield assertEquals(resp.status, Status.InternalServerError)
   }
+
+  ResourceFunFixture(
+    emberServerBuilder(defaultIdleTimeout, defaultHeaderTimeout)
+      .withMaxHeaderSize(100)
+      .build
+      .flatMap(server => clientResource(server.addressIp4s))
+  ).test(
+    "return 431 by default on excessive header size"
+  ) { client =>
+    val tooMuchHeader = "X-Trash: " + ("." * 120)
+    val request = Stream(s"GET / HTTP/1.0\r\n${tooMuchHeader}\r\n")
+    for {
+      _ <- client.writes(fs2.text.utf8.encode(request))
+      resp <- client.responsePrelude
+    } yield assertEquals(resp.status.code, 431)
+  }
+
+  ResourceFunFixture(
+    emberServerBuilder(defaultIdleTimeout, defaultHeaderTimeout)
+      .withMaxHeaderSize(100)
+      .withMaxHeaderSizeErrorHandler { case _ =>
+        IO.fromEither(Status.fromInt(499)).map(Response(_))
+      }
+      .build
+      .flatMap(server => clientResource(server.addressIp4s))
+  ).test(
+    "respect user configured behavior for excessive header size "
+  ) { client =>
+    val tooMuchHeader = "X-Trash: " + ("." * 120)
+    val request = Stream(s"GET / HTTP/1.0\r\n${tooMuchHeader}\r\n")
+    for {
+      _ <- client.writes(fs2.text.utf8.encode(request))
+      resp <- client.responsePrelude
+    } yield assertEquals(resp.status.code, 499)
+  }
+
 }