This is an automated email from the ASF dual-hosted git repository.
pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-grpc.git
The following commit(s) were added to refs/heads/main by this push:
new 4084ae5e Harden and test TLS hostname verification for the pekko-http
client (#857)
4084ae5e is described below
commit 4084ae5ee9bb9ad8904d94d43e2379e70583f14f
Author: PJ Fanning <[email protected]>
AuthorDate: Mon Aug 24 11:45:35 2026 +0100
Harden and test TLS hostname verification for the pekko-http client (#857)
Motivation:
Follow-up to #820 and #830. `ConnectionContext.httpsClient(SSLContext)`,
which the client used before #820, already sets both client mode and the
`https` endpoint identification algorithm, so hostname verification was
never absent. What #820 changed was to move to the
`httpsClient((host, port) => ...)` overload, which pekko-http marks
`@ApiMayChange` and documents as leaving SNI and hostname verification to
the caller. The secure default now depends on pekko-grpc reproducing that
setup correctly, and nothing tests or documents it.
Modification:
- The verifying path delegates to
`ConnectionContext.httpsClient(SSLContext)`
again, so the default rides on pekko-http's own maintained implementation.
The hand-built engine is confined to the `verify-hostname = false`
opt-out,
where it now also sets client mode: `Http.sslTlsStage` leaves the engine
entirely to the creator, so nothing sets it on the HTTP/1.1 path. Only
`Http2.outgoingConnection` happens to set it today, which is an internal
detail rather than a contract.
- Extracted `sslContextFor`, `insecureSslEngineCreator` and
`connectionContext`
as internal helpers so the verification contract is reachable from a test.
- The netty backend logs a warning when `verify-hostname = false` is set.
grpc-java's netty transport always verifies and exposes no switch, so the
setting was silently ignored on what is the default backend.
- Reworded the pekko-http warning: the only readers of it are users who
explicitly disabled verification, so pointing at the default is clearer
than telling them to set what they just overrode.
- New `HostnameVerificationSpec` performs real TLS handshakes against a
local
server whose certificate carries a single `DNS:localhost` SAN, asserting
that reaching it as `127.0.0.1` fails when verification is on and succeeds
when it is off. The pair is a differential guard: with endpoint
identification unset the rejecting case passes, since the certificate is
otherwise valid and trusted.
- Documented the setting, which hostname is actually checked (the authority,
not the discovered address), the production warning, and that it does not
apply to the netty backend.
The certificates under runtime/src/test/resources/certs are copied from
plugin-tester-scala/src/main/resources/certs in this repository.
Result:
The secure default is provided by pekko-http rather than reimplemented,
the opt-out no longer depends on an unrelated part of pekko-http to set
client mode, a silently ignored setting on the default backend is now
reported, and the verification contract is covered by tests and docs.
Tests:
- sbt "runtime/test" - 134 tests passed, 5 of them new
- sbt "runtime/mimaReportBinaryIssues" - passed
- sbt scalafmtCheckAll scalafmtSbtCheck - passed
- sbt docs/paradox - passed
- sbt headerCreateAll - applied
- sbt "sbt-plugin / scripted" - Not run, left to CI
- sbt +mimaReportBinaryIssues - Not run, single-version run passed, cross
version left to CI
References:
Refs #820, Refs #830
---
docs/src/main/paradox/client/configuration.md | 46 ++++++
.../pekko/grpc/internal/NettyClientUtils.scala | 9 ++
.../pekko/grpc/internal/PekkoHttpClientUtils.scala | 96 ++++++++-----
.../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 | 156 +++++++++++++++++++++
7 files changed, 341 insertions(+), 32 deletions(-)
diff --git a/docs/src/main/paradox/client/configuration.md
b/docs/src/main/paradox/client/configuration.md
index d5dc3b9b..bbe48862 100644
--- a/docs/src/main/paradox/client/configuration.md
+++ b/docs/src/main/paradox/client/configuration.md
@@ -36,6 +36,52 @@ Clients defined in configuration pick up defaults from
`reference.conf`:
`reference.conf`
: @@snip [reference](/runtime/src/main/resources/reference.conf) { #defaults }
+## TLS hostname verification
+
+When TLS is enabled the client verifies that the server's certificate matches
the hostname it
+connected to (RFC 2818), so a certificate that is otherwise valid and trusted
is still rejected if
+it was issued for a different host. This is controlled by `verify-hostname`,
which defaults to
+`true`:
+
+```hocon
+pekko.grpc.client."*" {
+ verify-hostname = true
+}
+```
+
+or programmatically:
+
+Scala
+: ```scala
+ val settings = GrpcClientSettings.connectToServiceAt("localhost", 8080)
+ .withVerifyHostname(true)
+ ```
+
+Java
+: ```java
+ GrpcClientSettings settings =
GrpcClientSettings.connectToServiceAt("localhost", 8080, system)
+ .withVerifyHostname(true);
+ ```
+
+The hostname that is checked is the authority the client connects to — that is
+`override-authority` when it is set, otherwise the service name — not the
address that service
+discovery resolved to. This matches how gRPC treats an overridden authority,
and it is what lets a
+client reach a server by IP while still verifying the certificate it expects.
+
+@@@ warning
+
+Setting `verify-hostname = false` accepts any trusted certificate regardless
of which host it was
+issued for, which removes the protection against a man-in-the-middle that
holds any certificate
+your trust store accepts. It exists for testing against certificates that do
not carry a matching
+name, and should not be used in production. The client logs a warning on every
channel it creates
+while it is disabled.
+
+The setting only applies to the `pekko-http` backend. The `netty` backend
always verifies the
+hostname and offers no switch to turn it off, so setting `verify-hostname =
false` there has no
+effect and is logged as a warning.
+
+@@@
+
## Using Pekko Discovery for Endpoint Discovery
The examples above all use a hard coded host and port for the location of the
gRPC service which is the default if you do not configure a
`service-discovery-mechanism`.
diff --git
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/NettyClientUtils.scala
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/NettyClientUtils.scala
index 83afe282..d8d89091 100644
---
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/NettyClientUtils.scala
+++
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/NettyClientUtils.scala
@@ -66,6 +66,15 @@ object NettyClientUtils {
if (!settings.useTls)
builder = builder.usePlaintext()
else {
+ // the setting cannot be honoured here: grpc-java's netty transport
always checks the server
+ // hostname against its certificate and does not expose a switch for it.
Say so rather than
+ // letting the client silently keep verifying.
+ if (!settings.verifyHostname)
+ log.warning(
+ "verify-hostname = false is ignored by the netty backend for client
'{}'; " +
+ "the netty backend always verifies the server hostname against its
certificate. " +
+ "Use the pekko-http backend if you need to disable verification.",
+ settings.serviceName)
builder = builder.negotiationType(NegotiationType.TLS)
builder = settings.sslContext match {
case Some(sslContext) =>
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 7b1cf9a0..e864f3ab 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
@@ -34,7 +34,7 @@ import pekko.stream.scaladsl.{ Keep, Sink, Source }
import pekko.util.ByteString
import io.grpc.{ CallOptions, MethodDescriptor, Status, StatusRuntimeException
}
-import javax.net.ssl.{ KeyManager, SSLContext, TrustManager }
+import javax.net.ssl.{ KeyManager, SSLContext, SSLEngine, TrustManager }
import scala.collection.immutable
import scala.concurrent.{ ExecutionContext, Future, Promise }
import scala.concurrent.duration.DurationLong
@@ -97,36 +97,7 @@ object PekkoHttpClientUtils {
val http2client =
if (settings.useTls) {
- if (!settings.verifyHostname) {
- log.warning(
- "TLS hostname verification is disabled for pekko-http client '{}'.
" +
- "This is insecure and should only be used for testing. " +
- "Set verify-hostname = true in your configuration (now the
default). " +
- "Note: the netty backend always verifies hostnames.",
- settings.serviceName)
- }
- val sslContext =
- settings.sslContext.getOrElse {
- settings.trustManager match {
- case None => SSLContext.getDefault
- case Some(trustManager) =>
- val ctx: SSLContext = SSLContext.getInstance("TLS")
- ctx.init(Array[KeyManager](),
Array[TrustManager](trustManager), new SecureRandom)
- ctx
- }
- }
- val connectionContext =
- ConnectionContext.httpsClient((hostname, port) => {
- val engine = sslContext.createSSLEngine(hostname, port)
- if (settings.verifyHostname) {
- val sslParams = engine.getSSLParameters
- sslParams.setEndpointIdentificationAlgorithm("HTTPS")
- engine.setSSLParameters(sslParams)
- }
- engine
- })
-
-
builder.withCustomHttpsConnectionContext(connectionContext).managedPersistentHttp2()
+ builder.withCustomHttpsConnectionContext(connectionContext(settings,
log)).managedPersistentHttp2()
} else {
builder.managedPersistentHttp2WithPriorKnowledge()
}
@@ -249,6 +220,67 @@ object PekkoHttpClientUtils {
}
}
+ /**
+ * INTERNAL API
+ *
+ * The `SSLContext` to use 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 ctx: SSLContext = SSLContext.getInstance("TLS")
+ ctx.init(Array[KeyManager](), Array[TrustManager](trustManager), new
SecureRandom)
+ ctx
+ }
+ }
+
+ /**
+ * INTERNAL API
+ *
+ * Builds the `SSLEngine` used when hostname verification is switched off.
+ *
+ * Client mode has to be set here: `Http.sslTlsStage` leaves the engine
entirely to this
+ * function, so nothing downstream would set it on the HTTP/1.1 path.
+ */
+ @InternalApi
+ private[grpc] def insecureSslEngineCreator(sslContext: SSLContext): (String,
Int) => SSLEngine =
+ (hostname, port) => {
+ val engine = sslContext.createSSLEngine(hostname, port)
+ engine.setUseClientMode(true)
+ engine
+ }
+
+ /**
+ * INTERNAL API
+ *
+ * The HTTPS connection context for the pekko-http backend.
+ *
+ * When hostname verification is on (the default) this delegates to
+ * `ConnectionContext.httpsClient(SSLContext)`, which sets both client mode
and the `https`
+ * endpoint identification algorithm. Hand-rolling that is what the opt-out
path is for:
+ * `ConnectionContext.httpsClient((host, port) => ...)` is `@ApiMayChange`
and documented as
+ * leaving SNI and hostname verification to the caller, so the secure
default should not
+ * depend on us reproducing it correctly.
+ */
+ @InternalApi
+ private[grpc] def connectionContext(settings: GrpcClientSettings, log:
LoggingAdapter): HttpsConnectionContext = {
+ val sslContext = sslContextFor(settings)
+ if (settings.verifyHostname) ConnectionContext.httpsClient(sslContext)
+ else {
+ log.warning(
+ "TLS hostname verification is disabled for pekko-http client '{}'. " +
+ "This is insecure and should only be used for testing. " +
+ "Remove verify-hostname = false from your configuration to restore the
default. " +
+ "Note: the netty backend always verifies hostnames.",
+ settings.serviceName)
+ ConnectionContext.httpsClient(insecureSslEngineCreator(sslContext))
+ }
+ }
+
/**
* 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..7ff098bf
--- /dev/null
+++
b/runtime/src/test/scala/org/apache/pekko/grpc/internal/HostnameVerificationSpec.scala
@@ -0,0 +1,156 @@
+/*
+ * 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.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 certificate in `/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.
+ */
+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 in.readAllBytes()
+ 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
+
+ private def settingsFor(host: String, verifyHostname: Boolean):
GrpcClientSettings =
+ GrpcClientSettings
+ .connectToServiceAt(host, binding.localAddress.getPort)
+
.withTrustManager(SSLContextUtils.trustManagerFromResource("/certs/rootCA.crt"))
+ .withVerifyHostname(verifyHostname)
+
+ /** Runs a real TLS handshake through the connection context our client code
builds. */
+ private def handshake(host: String, verifyHostname: Boolean):
Try[HttpResponse] = {
+ val context = PekkoHttpClientUtils.connectionContext(settingsFor(host,
verifyHostname), system.log)
+ 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 when verification is
on" in {
+ handshake("localhost", verifyHostname = true) 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 when
verification is on" in {
+ // this is the regression guard: with endpoint identification unset the
handshake below
+ // succeeds, because the certificate is otherwise valid and trusted
+ handshake("127.0.0.1", verifyHostname = true) 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
+ }
+ }
+ }
+
+ "accept a certificate that does not match the requested hostname when
verification is off" in {
+ handshake("127.0.0.1", verifyHostname = false) match {
+ case Success(response) => response.status shouldBe StatusCodes.OK
+ case Failure(e) => fail(s"expected the handshake to succeed,
got [$e]")
+ }
+ }
+ }
+
+ "insecureSslEngineCreator" should {
+
+ "leave endpoint identification unset" in {
+ val engine =
PekkoHttpClientUtils.insecureSslEngineCreator(SSLContext.getDefault)("example.com",
443)
+
+ // null or empty both mean "do not check"; the JDK returns null when
never set
+
Option(engine.getSSLParameters.getEndpointIdentificationAlgorithm).getOrElse("")
shouldBe ""
+ }
+
+ "set client mode, which Http.sslTlsStage leaves to the engine creator" in {
+ val engine =
PekkoHttpClientUtils.insecureSslEngineCreator(SSLContext.getDefault)("example.com",
443)
+
+ engine.getUseClientMode 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]