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]