CVE-2022-31183: Improper Certificate Validation (CWE-295)
value: fs2 is a compositional, streaming I/O library for Scala. When establishing a server-mode `TLSSocket` using `fs2-io` on Node.js, the parameter `requestCert = true` is ignored, peer certificate verification is skipped, and the connection proceeds. The vulnerability is limited to: 1. `fs2-io` running on Node.js. The JVM TLS implementation is completely independent. 2. `TLSSocket`s in server-mode. Client-mode `TLSSocket`s are implemented via a different API. 3. mTLS as enabled via `requestCert = true` in `TLSParameters`. The default setting is `false` for server-mode `TLSSocket`s. It was introduced with the initial Node.js implementation of fs2-io in 3.1.0. A patch is released in v3.2.11. The requestCert = true parameter is respected and the peer certificate is verified. If verification fails, a SSLException is raised. If using an unpatched version on Node.js, do not use a server-mode TLSSocket with requestCert = true to establish a mTLS connection.
CWE description: The product does not validate, or incorrectly validates, a certificate.
CVSS v3 base score: 9.8
Published: 2022-08-01T20:15Z
Repository: https://github.com/typelevel/fs2
Language: Scala
Files changed: io/js/src/test/scala/fs2/io/net/tls/TLSSocketSuite.scala, io/js/src/test/scala/fs2/io/net/tls/TLSSuite.scala
Commit date: 2022-07-22 15:42:06 -0700
Commit message: First attempt at test for GHSA-2cpx-6pqp-wf35

Diff: diff --git a/io/js/src/test/scala/fs2/io/net/tls/TLSSocketSuite.scala b/io/js/src/test/scala/fs2/io/net/tls/TLSSocketSuite.scala
index c25c42350..75b75df89 100644
--- a/io/js/src/test/scala/fs2/io/net/tls/TLSSocketSuite.scala
+++ b/io/js/src/test/scala/fs2/io/net/tls/TLSSocketSuite.scala
@@ -104,11 +104,11 @@ class TLSSocketSuite extends TLSSuite {
 
     test("echo") {
       val msg = Chunk.array(("Hello, world! " * 20000).getBytes)
 
       val setup = for {
-        tlsContext <- Resource.eval(testTlsContext)
+        tlsContext <- Resource.eval(testTlsContext(true))
         addressAndConnections <- Network[IO].serverResource(Some(ip"127.0.0.1"))
         (serverAddress, server) = addressAndConnections
         client <- Network[IO]
           .client(serverAddress)
           .flatMap(
@@ -178,7 +178,54 @@ class TLSSocketSuite extends TLSSuite {
         .compile
         .to(Chunk)
         .intercept[SSLException]
     }
 
+    test("mTLS client verification".only) { // GHSA-2cpx-6pqp-wf35
+      val msg = Chunk.array(("Hello, world! " * 20000).getBytes)
+
+      val setup = for {
+        serverContext <- Resource.eval(testTlsContext(true))
+        clientContext <- Resource.eval(testTlsContext(false))
+        addressAndConnections <- Network[IO].serverResource(Some(ip"127.0.0.1"))
+        (serverAddress, server) = addressAndConnections
+        client <- Network[IO]
+          .client(serverAddress)
+          .flatMap(
+            clientContext
+              .clientBuilder(_)
+              .withParameters(
+                TLSParameters(checkServerIdentity =
+                  Some((sn, _) => Either.cond(sn == "localhost", (), new RuntimeException()))
+                )
+              )
+              .build
+          )
+      } yield server.flatMap(s =>
+        Stream.resource(
+          serverContext
+            .serverBuilder(s)
+            .withParameters(TLSParameters(requestCert = true.some)) // mTLS
+            .build
+        )
+      ) -> client
+
+      Stream
+        .resource(setup)
+        .flatMap { case (server, clientSocket) =>
+          val echoServer = server.map { socket =>
+            socket.reads.chunks.foreach(socket.write(_))
+          }.parJoinUnbounded
+
+          val client =
+            Stream.exec(clientSocket.write(msg)) ++
+              clientSocket.reads.take(msg.size.toLong)
+
+          client.concurrently(echoServer)
+        }
+        .compile
+        .to(Chunk)
+        .intercept[SSLException]
+    }
+
   }
 }
diff --git a/io/js/src/test/scala/fs2/io/net/tls/TLSSuite.scala b/io/js/src/test/scala/fs2/io/net/tls/TLSSuite.scala
index 6bb2dd41c..3b8dec483 100644
--- a/io/js/src/test/scala/fs2/io/net/tls/TLSSuite.scala
+++ b/io/js/src/test/scala/fs2/io/net/tls/TLSSuite.scala
@@ -30,22 +30,25 @@ import fs2.io.file.Files
 import fs2.io.file.Path
 
 import scala.scalajs.js
 
 abstract class TLSSuite extends Fs2Suite {
-  def testTlsContext: IO[TLSContext[IO]] = Files[IO]
+
+  def testTlsContext(privateKey: Boolean): IO[TLSContext[IO]] = Files[IO]
     .readAll(Path("io/shared/src/test/resources/keystore.json"))
     .through(text.utf8.decode)
     .compile
     .string
     .flatMap(s => IO(js.JSON.parse(s).asInstanceOf[js.Dictionary[CertKey]]("server")))
     .map { certKey =>
       Network[IO].tlsContext.fromSecureContext(
         SecureContext(
           ca = List(certKey.cert.asRight).some,
           cert = List(certKey.cert.asRight).some,
-          key = List(SecureContext.Key(certKey.key.asRight, "password".some)).some
+          key =
+            if (privateKey) List(SecureContext.Key(certKey.key.asRight, "password".some)).some
+            else None
         )
       )
     }
 
   val logger = TLSLogger.Disabled

Vulnerable code:         tlsContext <- Resource.eval(testTlsContext)
  def testTlsContext: IO[TLSContext[IO]] = Files[IO]
          key = List(SecureContext.Key(certKey.key.asRight, "password".some)).some
Fixed code:         tlsContext <- Resource.eval(testTlsContext(true))
    test("mTLS client verification".only) { // GHSA-2cpx-6pqp-wf35
      val msg = Chunk.array(("Hello, world! " * 20000).getBytes)

      val setup = for {
        serverContext <- Resource.eval(testTlsContext(true))
        clientContext <- Resource.eval(testTlsContext(false))
        addressAndConnections <- Network[IO].serverResource(Some(ip"127.0.0.1"))
        (serverAddress, server) = addressAndConnections
        client <- Network[IO]
          .client(serverAddress)
          .flatMap(
            clientContext
              .clientBuilder(_)
              .withParameters(
                TLSParameters(checkServerIdentity =
                  Some((sn, _) => Either.cond(sn == "localhost", (), new RuntimeException()))
                )
              )
              .build
          )
      } yield server.flatMap(s =>
        Stream.resource(
          serverContext
            .serverBuilder(s)
            .withParameters(TLSParameters(requestCert = true.some)) // mTLS
            .build
        )
      ) -> client

      Stream
        .resource(setup)
        .flatMap { case (server, clientSocket) =>
          val echoServer = server.map { socket =>
            socket.reads.chunks.foreach(socket.write(_))
          }.parJoinUnbounded

          val client =
            Stream.exec(clientSocket.write(msg)) ++
              clientSocket.reads.take(msg.size.toLong)

          client.concurrently(echoServer)
        }
        .compile
        .to(Chunk)
        .intercept[SSLException]
    }


  def testTlsContext(privateKey: Boolean): IO[TLSContext[IO]] = Files[IO]
          key =
            if (privateKey) List(SecureContext.Key(certKey.key.asRight, "password".some)).some
            else None