mTLS client verification is skipped in fs2 on Node.js
Description
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. TLSSockets in server-mode. Client-mode TLSSockets are implemented via a different API. 3. mTLS as enabled via requestCert = true in TLSParameters. The default setting is false for server-mode TLSSockets. 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.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
co.fs2:fs2-ioMaven | >= 3.1.0, < 3.2.11 | 3.2.11 |
co.fs2:fs2-io_2.12Maven | >= 3.1.0, < 3.2.11 | 3.2.11 |
co.fs2:fs2-io_3Maven | >= 3.1.0, < 3.2.11 | 3.2.11 |
co.fs2:fs2-io_2.13Maven | >= 3.1.0, < 3.2.11 | 3.2.11 |
co.fs2:fs2-io_sjs1_2.13Maven | >= 3.1.0, < 3.2.11 | 3.2.11 |
co.fs2:fs2-io_sjs1_3Maven | >= 3.1.0, < 3.2.11 | 3.2.11 |
Affected products
1Patches
219ce392e8093Merge pull request from GHSA-2cpx-6pqp-wf35
5 files changed · +94 −11
io/js/src/main/scala/fs2/io/internal/facade/events.scala+3 −0 modified@@ -38,6 +38,9 @@ private[io] trait EventEmitter extends js.Object { protected[io] def on[E, F](eventName: String, listener: js.Function2[E, F, Unit]): this.type = js.native + protected[io] def once(eventName: String, listener: js.Function0[Unit]): this.type = + js.native + protected[io] def once[E](eventName: String, listener: js.Function1[E, Unit]): this.type = js.native
io/js/src/main/scala/fs2/io/internal/facade/tls.scala+7 −0 modified@@ -189,6 +189,13 @@ package tls { def alpnProtocol: String | Boolean = js.native + def ssl: SSL = js.native + + } + + @js.native + private[io] trait SSL extends js.Object { + def verifyError(): js.Error = js.native } }
io/js/src/main/scala/fs2/io/net/tls/TLSContextPlatform.scala+31 −8 modified@@ -30,6 +30,8 @@ import cats.effect.std.Dispatcher import cats.syntax.all._ import fs2.io.internal.facade +import scala.scalajs.js + private[tls] trait TLSContextPlatform[F[_]] private[tls] trait TLSContextCompanionPlatform { self: TLSContext.type => @@ -69,14 +71,35 @@ private[tls] trait TLSContextCompanionPlatform { self: TLSContext.type => } ) } else { - val options = params.toTLSSocketOptions(dispatcher) - options.secureContext = context - options.enableTrace = logger != TLSLogger.Disabled - options.isServer = true - TLSSocket.forAsync( - socket, - sock => new facade.tls.TLSSocket(sock, options) - ) + Resource.eval(F.deferred[Either[Throwable, Unit]]).flatMap { verifyError => + TLSSocket + .forAsync( + socket, + sock => { + val options = params.toTLSSocketOptions(dispatcher) + options.secureContext = context + options.enableTrace = logger != TLSLogger.Disabled + options.isServer = true + val tlsSock = new facade.tls.TLSSocket(sock, options) + tlsSock.once( + "secure", + { () => + val requestCert = options.requestCert.getOrElse(false) + val rejectUnauthorized = options.rejectUnauthorized.getOrElse(true) + val result = + if (requestCert && rejectUnauthorized) + Option(tlsSock.ssl.verifyError()) + .map(e => new JavaScriptSSLException(js.JavaScriptException(e))) + .toLeft(()) + else Either.unit + dispatcher.unsafeRunAndForget(verifyError.complete(result)) + } + ) + tlsSock + } + ) + .evalTap(_ => verifyError.get.rethrow) + } } } .adaptError { case IOException(ex) => ex }
io/js/src/test/scala/fs2/io/net/tls/TLSSocketSuite.scala+48 −1 modified@@ -106,7 +106,7 @@ class TLSSocketSuite extends TLSSuite { 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] @@ -180,5 +180,52 @@ class TLSSocketSuite extends TLSSuite { .intercept[SSLException] } + test("mTLS client verification") { // 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] + } + } }
io/js/src/test/scala/fs2/io/net/tls/TLSSuite.scala+5 −2 modified@@ -32,7 +32,8 @@ 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 @@ -43,7 +44,9 @@ abstract class TLSSuite extends Fs2Suite { 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 ) ) }
659824395826First attempt at test for GHSA-2cpx-6pqp-wf35
2 files changed · +53 −3
io/js/src/test/scala/fs2/io/net/tls/TLSSocketSuite.scala+48 −1 modified@@ -106,7 +106,7 @@ class TLSSocketSuite extends TLSSuite { 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] @@ -180,5 +180,52 @@ class TLSSocketSuite extends TLSSuite { .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] + } + } }
io/js/src/test/scala/fs2/io/net/tls/TLSSuite.scala+5 −2 modified@@ -32,7 +32,8 @@ 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 @@ -43,7 +44,9 @@ abstract class TLSSuite extends Fs2Suite { 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 ) ) }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-2cpx-6pqp-wf35ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-31183ghsaADVISORY
- github.com/nodejs/node/issues/43994ghsax_refsource_MISCWEB
- github.com/typelevel/fs2/commit/19ce392e8093d9571387dbd78e159e655a85aeeaghsaWEB
- github.com/typelevel/fs2/commit/659824395826a314e0a4331535dbf1ef8bef8207ghsax_refsource_MISCWEB
- github.com/typelevel/fs2/releases/tag/v3.2.11ghsaWEB
- github.com/typelevel/fs2/security/advisories/GHSA-2cpx-6pqp-wf35ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.