This is an automated email from the ASF dual-hosted git repository.

pjfanning pushed a commit to branch 1.3.x
in repository https://gitbox.apache.org/repos/asf/pekko-grpc.git


The following commit(s) were added to refs/heads/1.3.x by this push:
     new 56f978f9 Test that the pekko-http client verifies the server hostname 
(1.3.x) (#859)
56f978f9 is described below

commit 56f978f9b19f8605707cc8322412188e3a64cf9b
Author: PJ Fanning <[email protected]>
AuthorDate: Sun Aug 23 19:16:36 2026 +0100

    Test that the pekko-http client verifies the server hostname (1.3.x) (#859)
    
    * Test that the pekko-http client verifies the server hostname
    
    Motivation:
    Nothing covered hostname verification on this branch, which is how the
    regression in #845 reached it unnoticed. The behaviour is not written in
    this project at all - it comes from 
`ConnectionContext.httpsClient(SSLContext)`
    setting the `https` endpoint identification algorithm - so it is exactly the
    kind of implicit contract that a future change can drop silently.
    
    Modification:
    Extracted `sslContextFor` and `connectionContext` from `createChannel`. This
    is a pure refactor with no behaviour change, but it is needed: driving the
    check through `createChannel` does not work, because 
`managedPersistentHttp2`
    wraps the connection in `PersistentConnection`, which retries connection
    failures and breaks the stream rather than propagating the cause. A rejected
    handshake is then indistinguishable from any other connection problem - both
    hosts below surface as the same `StatusRuntimeException`.
    
    `HostnameVerificationSpec` runs real TLS handshakes through the connection
    context the client builds, against a local server whose certificate carries 
a
    single `DNS:localhost` SAN. Reaching that same server as `127.0.0.1` is a
    hostname mismatch and nothing else - same server, same trust store - so the
    two cases isolate the hostname check from a general trust or TLS problem.
    
    Confirmed the guard bites: with `connectionContext` changed to build the
    engine without endpoint identification, "reject a certificate that does not
    match the requested hostname" fails and the matching case still passes.
    
    The certificates under runtime/src/test/resources/certs are copied from
    plugin-tester-scala/src/main/resources/certs on the main branch.
    
    Result:
    The verification contract is pinned by a test that fails if the connection
    context is swapped for one that leaves hostname checking off.
    
    Tests:
    - sbt "runtime/test" - 112 tests passed, 2 of them new
    - sbt "runtime/mimaReportBinaryIssues" - passed
    - sbt scalafmtCheckAll scalafmtSbtCheck - passed
    - negative control: verification disabled in connectionContext, the guard 
test
      fails as intended
    
    References:
    Refs #820, Refs #845, Refs #858
    
    * Update HostnameVerificationSpec.scala
    
    * read the test resource without InputStream.readAllBytes
    
    Motivation:
    `InputStream.readAllBytes` is JDK 9 API and this branch is built on JDK 8,
    where compilation fails with "value readAllBytes is not a member of
    java.io.InputStream".
    
    Modification:
    Read the resource into a ByteArrayOutputStream in a loop instead.
    
    Result:
    The runtime tests compile and run on JDK 8 again.
    
    Tests:
    - JAVA_HOME=<jdk8> sbt runtime/Test/compile - pass
    - JAVA_HOME=<jdk8> sbt "runtime/testOnly 
org.apache.pekko.grpc.internal.HostnameVerificationSpec" - pass, both cases
    
    References:
    Refs #859
---
 .../pekko/grpc/internal/PekkoHttpClientUtils.scala |  50 ++++---
 .../src/test/resources/certs/localhost-server.crt  |  20 +++
 .../src/test/resources/certs/localhost-server.key  |  28 ++++
 runtime/src/test/resources/certs/rootCA.crt        |  18 +++
 .../grpc/internal/HostnameVerificationSpec.scala   | 145 +++++++++++++++++++++
 5 files changed, 246 insertions(+), 15 deletions(-)

diff --git 
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtils.scala
 
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtils.scala
index 3567bf1e..0c486fe3 100644
--- 
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtils.scala
+++ 
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtils.scala
@@ -25,7 +25,7 @@ import pekko.event.LoggingAdapter
 import pekko.grpc.GrpcProtocol.GrpcProtocolReader
 import pekko.grpc.{ GrpcClientSettings, GrpcResponseMetadata, 
GrpcSingleResponse, ProtobufSerializer }
 import pekko.http.scaladsl.model.HttpEntity.{ Chunk, Chunked, LastChunk, 
Strict }
-import pekko.http.scaladsl.{ ClientTransport, ConnectionContext, Http }
+import pekko.http.scaladsl.{ ClientTransport, ConnectionContext, Http, 
HttpsConnectionContext }
 import pekko.http.scaladsl.model._
 import pekko.http.scaladsl.model.headers.RawHeader
 import pekko.http.scaladsl.settings.ClientConnectionSettings
@@ -94,20 +94,7 @@ object PekkoHttpClientUtils {
 
     val http2client =
       if (settings.useTls) {
-        val connectionContext =
-          ConnectionContext.httpsClient {
-            settings.sslContext.getOrElse {
-              settings.trustManager match {
-                case None => SSLContext.getDefault
-                case Some(trustManager) =>
-                  val sslContext: SSLContext = SSLContext.getInstance("TLS")
-                  sslContext.init(Array[KeyManager](), 
Array[TrustManager](trustManager), new SecureRandom)
-                  sslContext
-              }
-            }
-          }
-
-        
builder.withCustomHttpsConnectionContext(connectionContext).managedPersistentHttp2()
+        
builder.withCustomHttpsConnectionContext(connectionContext(settings)).managedPersistentHttp2()
       } else {
         builder.managedPersistentHttp2WithPriorKnowledge()
       }
@@ -189,6 +176,39 @@ object PekkoHttpClientUtils {
     }
   }
 
+  /**
+   * INTERNAL API
+   *
+   * The `SSLContext` for the pekko-http backend, from the explicitly 
configured context, the
+   * configured trust manager, or the JVM default.
+   */
+  @InternalApi
+  private[grpc] def sslContextFor(settings: GrpcClientSettings): SSLContext =
+    settings.sslContext.getOrElse {
+      settings.trustManager match {
+        case None => SSLContext.getDefault
+        case Some(trustManager) =>
+          val sslContext: SSLContext = SSLContext.getInstance("TLS")
+          sslContext.init(Array[KeyManager](), 
Array[TrustManager](trustManager), new SecureRandom)
+          sslContext
+      }
+    }
+
+  /**
+   * INTERNAL API
+   *
+   * The HTTPS connection context for the pekko-http backend.
+   *
+   * `ConnectionContext.httpsClient(SSLContext)` sets client mode and the 
`https` endpoint
+   * identification algorithm, which is where hostname verification comes 
from. Kept as a separate
+   * method so that contract is reachable from a test: through `createChannel` 
it is not, because
+   * `managedPersistentHttp2` retries connection failures rather than 
surfacing them, so a
+   * rejected handshake is indistinguishable from any other connection problem.
+   */
+  @InternalApi
+  private[grpc] def connectionContext(settings: GrpcClientSettings): 
HttpsConnectionContext =
+    ConnectionContext.httpsClient(sslContextFor(settings))
+
   /**
    * INTERNAL API
    */
diff --git a/runtime/src/test/resources/certs/localhost-server.crt 
b/runtime/src/test/resources/certs/localhost-server.crt
new file mode 100644
index 00000000..0b309d25
--- /dev/null
+++ b/runtime/src/test/resources/certs/localhost-server.crt
@@ -0,0 +1,20 @@
+-----BEGIN CERTIFICATE-----
+MIIDSjCCAjKgAwIBAgIJAJxuw7Wkhgc5MA0GCSqGSIb3DQEBCwUAMDkxCzAJBgNV
+BAYTAlNFMRQwEgYDVQQHDAtFeGFtcGxldG93bjEUMBIGA1UECgwLRXhhbXBsZSBJ
+bmMwIBcNMjMwNTAyMTM0NzM4WhgPMjEyMzA0MDgxMzQ3MzhaMBQxEjAQBgNVBAMM
+CWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANBsMucJ
+duEpgqi8RjQfR2gr/exaH0lS8GC2fzcDDrcuUJTbuqYX09Hea0Qg/Nx5HsSyhdN0
+HI3jFum/boOIGQtskrkId6vQUa1gZEws/qkPdQEcz2sY/G41UNSLZESzGaRhYvvD
+PjydmCUdBftV5gHoDKy0vdHIP44FRIwz/xeVEc/W5QcPa8DDuQDURHZyGYeAA6ZC
+5yQicNo7J/n85xx6VMDy6V9PpB6UR0id0v2luSyCKoIhoKxfVnU5aRN6QjHfLvys
+G8kyqjTFd11TYb4B99eC6uN0IutMTSvvxqzuhM8vZ38Ck4HjqO4qBy4D9w6RxZnP
+4zTksPqlYwNaeaUCAwEAAaN4MHYwUwYDVR0jBEwwSqE9pDswOTELMAkGA1UEBhMC
+U0UxFDASBgNVBAcMC0V4YW1wbGV0b3duMRQwEgYDVQQKDAtFeGFtcGxlIEluY4IJ
+AMm3mmQCkdyOMAkGA1UdEwQCMAAwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqG
+SIb3DQEBCwUAA4IBAQBENcH6yNMywa7tBtelqADzds+yryVEGPNLkGABGdwzDxSm
+6tHZXdl1saT+AjhkoEFDABWhhqSkEMbObLIFtKpadtsRwq0OO1CoM7Bv0nsv5jSO
+s4mwdF4pO+4CxZARjQmfOXISiv6peDnmFlxK/sUDJUmUsY0gy6cZF4O+1NrQn4Np
+ew860WvR6L4JT80oDzKzqkHz52RzaRq1QmcvdohVVy8C8A9YrdIbKgQUhugIsVsq
+OOqzfBFcqkeywzdzWq5VmBS0bjNNz5+wpPKsCk7eXeFRLE/awkD2GeZoLw3LDPEg
+YdvNxWzymoNlB/MiP4HdGOK1DNcHLuwyOGkcNkV7
+-----END CERTIFICATE-----
diff --git a/runtime/src/test/resources/certs/localhost-server.key 
b/runtime/src/test/resources/certs/localhost-server.key
new file mode 100644
index 00000000..25c1f340
--- /dev/null
+++ b/runtime/src/test/resources/certs/localhost-server.key
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEwAIBADANBgkqhkiG9w0BAQEFAASCBKowggSmAgEAAoIBAQDQbDLnCXbhKYKo
+vEY0H0doK/3sWh9JUvBgtn83Aw63LlCU27qmF9PR3mtEIPzceR7EsoXTdByN4xbp
+v26DiBkLbJK5CHer0FGtYGRMLP6pD3UBHM9rGPxuNVDUi2REsxmkYWL7wz48nZgl
+HQX7VeYB6AystL3RyD+OBUSMM/8XlRHP1uUHD2vAw7kA1ER2chmHgAOmQuckInDa
+Oyf5/OccelTA8ulfT6QelEdIndL9pbksgiqCIaCsX1Z1OWkTekIx3y78rBvJMqo0
+xXddU2G+AffXgurjdCLrTE0r78as7oTPL2d/ApOB46juKgcuA/cOkcWZz+M05LD6
+pWMDWnmlAgMBAAECggEBALCrBnrQivRRO2/MJ7YGzYB/yb2OpvaAV0GjcDIxZUfg
++m0z1AL2L5a18jbNv4kjIfGZYdbblViwJbv9iK/1rUUBw10U0FvTOWi9TEdF3Jdx
+grxur2MYyuCgUOPZRCT3q8SqyDygQyEedNkAwRFKvqzfBd9fVYd9NmIsFO7DJHfX
+XFUJuxz4z0IrxW5XsTQ7PLPtbcz3hZYF6GSZlCBAw6A7rKCGvjqOXmRFuz5t0tk5
+HqIBEFcMaGFQOhxsX9L5U6LseuKKj30A6OFsd1yLa5r42towaXoSY0XveRiaAYRu
+4N1+zETCCQE/HHMNYfMpNDz6AtkErcDs28TFqIlSDAECgYEA8JlqSa+8umimP6cj
+3aft1zZ9M+voCKntxkIcdJ1dS8MfrqFwTZz5pMzwKYtdCT8Dhw4ZyOA1Stxg2pPF
+cW7XIHhMSlWRdfnrEGwHPGgZ2L5k9MtHZrSYCCJCSqqZjOSbosEfpuyzDTqgA0+S
+rHMYXkoWFRAThRu3yIHK41X3PhcCgYEA3cOFTnsQuCaW81TWAeKpfWFVPJVRDQOX
+LjuAprVNJ+EGiMuDWfvKFcuTdG4FuOawY9ZMbQYRNEZLoSGSEU4bSR2OCXMr0Wyy
+fG2C1cFN2QrylrhrkrvbFajOJK4UCnQLof92yRuuuUlKlqRMDN2rtcorLJ3BpSae
+noK6JHmBN6MCgYEAo2XNRVXQOliv7zK3rOVLJYmf5g8keh3NmYN0h84Helh9v79r
+4YnmEQINaGl5ObpNzv7IjB+YkcqxDECnKq4385k/VoxeSVz9Qx3anC+mvggvz//t
+8dZcGcoKc2MA/SqUeCfoMxk1UJqr6RO1bOCNgBuYe517ZD66xbU/8LyFOOkCgYEA
+02VTiSmFGZYnpRPE4Y049j03bIYF+jrm/XpZPBFt2EsI2JPvxXJhBH/IM1/B8q1t
+je41cmQrOEKeS55dyENFfWBACsAQEBXm2vfllXAsjm6CK6znVrver3n38D1E+2X9
+xNJqYHEUEKpOAOXjXQxeZ++tUl2bv5vd7so9ORHeXLMCgYEAkngbi1raD6RDRj+3
+8wvN/fl9s37l+9yCfj7VfeqQ899ypEBbPhFZtpTmkFtratMTEE2je4YxB4av45Zt
+Obzd1nBcWNyuEEq2sSxfjUnS3ruAEFM8aLK4mOgsm9mtni6OVel9BI9qY2sCWNXr
+BABBs7f/lr//lKFjor/fYIB1UsY=
+-----END PRIVATE KEY-----
diff --git a/runtime/src/test/resources/certs/rootCA.crt 
b/runtime/src/test/resources/certs/rootCA.crt
new file mode 100644
index 00000000..c2e4953d
--- /dev/null
+++ b/runtime/src/test/resources/certs/rootCA.crt
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE-----
+MIIC8DCCAdgCCQDJt5pkApHcjjANBgkqhkiG9w0BAQsFADA5MQswCQYDVQQGEwJT
+RTEUMBIGA1UEBwwLRXhhbXBsZXRvd24xFDASBgNVBAoMC0V4YW1wbGUgSW5jMCAX
+DTIzMDUwMjEzNDQ1OFoYDzIxMjMwNDA4MTM0NDU4WjA5MQswCQYDVQQGEwJTRTEU
+MBIGA1UEBwwLRXhhbXBsZXRvd24xFDASBgNVBAoMC0V4YW1wbGUgSW5jMIIBIjAN
+BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxrDswRWKKj/HGl9IFTX1Ux4+3cc+
+XMuDdnA70sixEtWPKPRvgJqRb8WZd6ED6BPnZltWppAPB8AwHm3IDdMaEtGIKup0
+NaWZf2d+lqA/5caayGfZpu1IYUFJICAq8SdlF5LOwXWznP+g7632tRukAJk5IoC9
+F5C6GxExqL38waiLUj7FrQaw0vMi9lDS30pPVK4g3msoaD60qI2SjtPL0qO8iP6Z
+FUI79J7ugZNgFYUaxDRVIKCG2pknTcD2nx+n1AX+tcyQN4ybf9aIv5TyoE5Yki6I
++k2a3sDNzouxLTKPlgt5iCqU0440PfkYhP1rJ5p8cUEmwaSLaHUaRRu8YwIDAQAB
+MA0GCSqGSIb3DQEBCwUAA4IBAQAVAOmoL+wMIoqDgTfqwLsEGA7FiE1HerDE0mEv
+LcMECVavawexrsl2G4dao+YTMaRC0761aoIrxRoh8nm/jxly+ZWYPNXRVlMRtfMx
+WVALEOHhVJH/83swQzVNbuk/kz91Jeg0VS90OAw6QeO3ELg5HKqxdjqxr1+Emsz1
+q47dK2BWIMA5ux+pzL9jf1KVrx/tX3lnZH1Fr2Pup/l3FrHeLO+N6gFUirgiTebH
+JadlTQu+wM+CNVWy2n8tZLjkWZVPdI+D/WBe8wEznMHG1l4FOqsTO3FuxYCsFB+T
+M0YwXvSI+kjljc9ZEKEmuA1xwZqIt1MEJ0K7crSlMl55j1K6
+-----END CERTIFICATE-----
diff --git 
a/runtime/src/test/scala/org/apache/pekko/grpc/internal/HostnameVerificationSpec.scala
 
b/runtime/src/test/scala/org/apache/pekko/grpc/internal/HostnameVerificationSpec.scala
new file mode 100644
index 00000000..d79db5f9
--- /dev/null
+++ 
b/runtime/src/test/scala/org/apache/pekko/grpc/internal/HostnameVerificationSpec.scala
@@ -0,0 +1,145 @@
+/*
+ * 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.
+ */
+
+package org.apache.pekko.grpc.internal
+
+import java.io.ByteArrayOutputStream
+import java.nio.charset.StandardCharsets.UTF_8
+import java.security.{ KeyFactory, KeyStore, SecureRandom }
+import java.security.cert.CertificateFactory
+import java.security.spec.PKCS8EncodedKeySpec
+import java.util.Base64
+
+import javax.net.ssl.{ KeyManagerFactory, SSLContext, SSLHandshakeException }
+
+import scala.concurrent.Future
+import scala.concurrent.duration._
+import scala.util.{ Failure, Success, Try }
+
+import org.apache.pekko
+import pekko.actor.ActorSystem
+import pekko.grpc.{ GrpcClientSettings, SSLContextUtils }
+import pekko.http.scaladsl.{ ConnectionContext, Http }
+import pekko.http.scaladsl.model.{ HttpRequest, HttpResponse, StatusCodes }
+import pekko.stream.scaladsl.{ Sink, Source }
+import pekko.testkit.TestKit
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.concurrent.ScalaFutures
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.time.Span
+import org.scalatest.wordspec.AnyWordSpecLike
+
+/**
+ * The pekko-http backend gets hostname verification from
+ * `ConnectionContext.httpsClient(SSLContext)`, which sets the `https` 
endpoint identification
+ * algorithm. Nothing here sets it explicitly, so this spec pins the 
behaviour: it is the guard
+ * against that connection context being swapped for one that leaves 
verification off.
+ *
+ * `/certs/localhost-server.crt` carries a single `DNS:localhost` SAN, so 
reaching the same server
+ * as `127.0.0.1` is a hostname mismatch and nothing else - same server, same 
trust store.
+ */
+class HostnameVerificationSpec
+    extends TestKit(ActorSystem())
+    with AnyWordSpecLike
+    with Matchers
+    with ScalaFutures
+    with BeforeAndAfterAll {
+
+  implicit val patience: PatienceConfig =
+    PatienceConfig(10.seconds, Span(100, org.scalatest.time.Millis))
+
+  private def resourceBytes(path: String): Array[Byte] = {
+    val in = getClass.getResourceAsStream(path)
+    try {
+      // no InputStream.readAllBytes here, this branch still builds on JDK 8
+      val out = new ByteArrayOutputStream()
+      val buffer = new Array[Byte](8192)
+      var read = in.read(buffer)
+      while (read != -1) {
+        out.write(buffer, 0, read)
+        read = in.read(buffer)
+      }
+      out.toByteArray
+    } finally in.close()
+  }
+
+  private def serverSslContext(): SSLContext = {
+    val cert = CertificateFactory
+      .getInstance("X.509")
+      
.generateCertificate(getClass.getResourceAsStream("/certs/localhost-server.crt"))
+    val pem = new String(resourceBytes("/certs/localhost-server.key"), UTF_8)
+      .replace("-----BEGIN PRIVATE KEY-----", "")
+      .replace("-----END PRIVATE KEY-----", "")
+      .replaceAll("\\s", "")
+    val key = KeyFactory.getInstance("RSA").generatePrivate(new 
PKCS8EncodedKeySpec(Base64.getDecoder.decode(pem)))
+
+    val keyStore = KeyStore.getInstance("PKCS12")
+    keyStore.load(null, null)
+    keyStore.setKeyEntry("server", key, Array.emptyCharArray, Array(cert))
+    val kmf = 
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm)
+    kmf.init(keyStore, Array.emptyCharArray)
+
+    val ctx = SSLContext.getInstance("TLS")
+    ctx.init(kmf.getKeyManagers, null, new SecureRandom)
+    ctx
+  }
+
+  private val binding =
+    Http()
+      .newServerAt("127.0.0.1", 0)
+      .enableHttps(ConnectionContext.httpsServer(serverSslContext()))
+      .bind(_ => Future.successful(HttpResponse(StatusCodes.OK)))
+      .futureValue
+
+  /** Runs a real TLS handshake through the connection context the client code 
builds. */
+  private def handshake(host: String): Try[HttpResponse] = {
+    val settings = GrpcClientSettings
+      .connectToServiceAt(host, binding.localAddress.getPort)
+      
.withTrustManager(SSLContextUtils.trustManagerFromResource("/certs/rootCA.crt"))
+    val context = PekkoHttpClientUtils.connectionContext(settings)
+    val connection = Http().outgoingConnectionHttps(host, 
binding.localAddress.getPort, context)
+    Try(Source.single(HttpRequest(uri = 
"/")).via(connection).runWith(Sink.head).futureValue)
+  }
+
+  "The pekko-http client connection context" should {
+
+    "accept a certificate matching the requested hostname" in {
+      handshake("localhost") match {
+        case Success(response) => response.status shouldBe StatusCodes.OK
+        case Failure(e)        => fail(s"expected the handshake to succeed, 
got [$e]")
+      }
+    }
+
+    "reject a certificate that does not match the requested hostname" in {
+      // the regression guard: with endpoint identification unset this 
handshake succeeds,
+      // because the certificate is otherwise valid and trusted
+      handshake("127.0.0.1") match {
+        case Success(response) => fail(s"expected the handshake to fail, got 
[$response]")
+        case Failure(e) =>
+          val causes = Iterator.iterate(e)(_.getCause).takeWhile(_ ne 
null).toList
+          withClue(causes.mkString(", ")) {
+            causes.exists(_.isInstanceOf[SSLHandshakeException]) shouldBe true
+          }
+      }
+    }
+  }
+
+  override def afterAll(): Unit = {
+    binding.terminate(5.seconds).futureValue
+    super.afterAll()
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to