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-connectors.git


The following commit(s) were added to refs/heads/main by this push:
     new 00a8f6d19 Elasticsearch: fix insecure-credentials check message and 
failure mode (#1871)
00a8f6d19 is described below

commit 00a8f6d193c6c0967a3fb27fae3dc920ea09c63f
Author: PJ Fanning <[email protected]>
AuthorDate: Tue Sep 22 10:45:52 2026 +0100

    Elasticsearch: fix insecure-credentials check message and failure mode 
(#1871)
    
    Motivation:
    The HTTPS enforcement added in #1810 has three problems.
    
    1. The error message never interpolates the scheme. In
    
         "... scheme is '%s' (not 'https'). " +
         "... insecure. " +
         "Configure a HTTPS base URL ...".format(scheme)
    
       `.format` binds to the last string literal only, which contains no
       `%s`, so the message is emitted with a literal '%s'.
    
    2. The check throws synchronously. ElasticsearchSimpleFlowStage.onPush
       calls executeRequest outside any try/catch, so the exception escapes
       the stage instead of going through the connector's failure handling.
       ElasticsearchSourceStage happens to wrap its call, so the two stages
       behaved differently.
    
    3. There is no way to opt out. The documented #connection-settings
       snippet pairs a http:// base URL with withCredentials, so a
       previously working local setup now fails outright with no override.
    
    Additionally, the credentials branch did not pass connectionContext, so
    a connection context supplied via withSSLContext was dropped whenever
    credentials were configured.
    
    Modification:
    Parenthesise the concatenation so format applies to the whole message.
    Return a failed Future rather than throwing, so both stages route the
    error through their own failure handling. Add
    withAllowInsecureCredentialsTransport (default false) as an opt-out that
    downgrades the rejection to a warning. Pass connectionContext in the
    credentials branch as well.
    
    The scheme decision is extracted into checkCredentialsTransport so it can
    be tested without opening a socket. The documented snippets now use a
    https:// base URL with credentials, which is the configuration users
    should copy.
    
    Result:
    The message names the actual scheme. Failures arrive as a failed Future
    and are reported by the connector rather than escaping the stage. Users
    who need plain HTTP with credentials for local testing can opt in. A
    user-supplied connection context is honoured with credentials set.
    
    Tests:
    Extended ElasticsearchApiSpec: the rejection is asserted on the failed
    Future, a new test asserts the scheme is interpolated and no '%s'
    remains, and four tests cover checkCredentialsTransport (reject http,
    accept https, accept HTTPS case-insensitively, warn when opted in).
    14 tests pass. Ran elasticsearch/mimaReportBinaryIssues (clean) and the
    scalafmt checks for the module.
    
    References:
    Follow-up to #1810.
---
 .../ElasticsearchConnectionSettings.scala          | 41 +++++++++++----
 .../elasticsearch/impl/ElasticsearchApi.scala      | 47 ++++++++++++-----
 .../javadsl/ElasticsearchParameterizedTest.java    |  2 +-
 .../docs/javadsl/OpensearchParameterizedTest.java  |  2 +-
 .../scaladsl/ElasticsearchConnectorBehaviour.scala |  2 +-
 .../elasticsearch/impl/ElasticsearchApiSpec.scala  | 60 ++++++++++++++++++++--
 6 files changed, 125 insertions(+), 29 deletions(-)

diff --git 
a/elasticsearch/src/main/scala/org/apache/pekko/stream/connectors/elasticsearch/ElasticsearchConnectionSettings.scala
 
b/elasticsearch/src/main/scala/org/apache/pekko/stream/connectors/elasticsearch/ElasticsearchConnectionSettings.scala
index 3540ef358..940488347 100644
--- 
a/elasticsearch/src/main/scala/org/apache/pekko/stream/connectors/elasticsearch/ElasticsearchConnectionSettings.scala
+++ 
b/elasticsearch/src/main/scala/org/apache/pekko/stream/connectors/elasticsearch/ElasticsearchConnectionSettings.scala
@@ -27,7 +27,8 @@ final class ElasticsearchConnectionSettings private (
     val username: Option[String],
     val password: Option[String],
     val headers: List[HttpHeader],
-    val connectionContext: Option[HttpsConnectionContext]) {
+    val connectionContext: Option[HttpsConnectionContext],
+    val allowInsecureCredentialsTransport: Boolean) {
 
   def withBaseUrl(value: String): ElasticsearchConnectionSettings = 
copy(baseUrl = value)
 
@@ -63,30 +64,52 @@ final class ElasticsearchConnectionSettings private (
 
   def hasConnectionContextDefined: Boolean = connectionContext.isDefined
 
+  /**
+   * Permit sending credentials over a plain HTTP connection. Defaults to 
`false`,
+   * in which case requests that would send credentials over a non-HTTPS 
connection
+   * fail instead. Enable only for local development and testing, where the 
traffic
+   * cannot be observed.
+   *
+   * @since 2.0.0
+   */
+  def withAllowInsecureCredentialsTransport(value: Boolean): 
ElasticsearchConnectionSettings =
+    copy(allowInsecureCredentialsTransport = value)
+
   private def copy(
       baseUrl: String = baseUrl,
       username: Option[String] = username,
       password: Option[String] = password,
       headers: List[HttpHeader] = headers,
-      connectionContext: Option[HttpsConnectionContext] = connectionContext): 
ElasticsearchConnectionSettings =
+      connectionContext: Option[HttpsConnectionContext] = connectionContext,
+      allowInsecureCredentialsTransport: Boolean = 
allowInsecureCredentialsTransport)
+      : ElasticsearchConnectionSettings =
     new ElasticsearchConnectionSettings(baseUrl = baseUrl,
       username = username,
       password = password,
       headers = headers,
-      connectionContext = connectionContext)
-
-  override def toString =
-    
s"""ElasticsearchConnectionSettings(baseUrl=$baseUrl,username=$username,password=${password.fold("")(_
 =>
-        
"***")},headers=${headers.mkString(";")},connectionContext=$connectionContext)"""
+      connectionContext = connectionContext,
+      allowInsecureCredentialsTransport = allowInsecureCredentialsTransport)
+
+  override def toString = {
+    val maskedPassword = password.fold("")(_ => "***")
+    val renderedHeaders = headers.mkString(";")
+    "ElasticsearchConnectionSettings(" +
+    s"baseUrl=$baseUrl," +
+    s"username=$username," +
+    s"password=$maskedPassword," +
+    s"headers=$renderedHeaders," +
+    s"connectionContext=$connectionContext," +
+    s"allowInsecureCredentialsTransport=$allowInsecureCredentialsTransport)"
+  }
 }
 
 object ElasticsearchConnectionSettings {
 
   /** Scala API */
   def apply(baseUrl: String): ElasticsearchConnectionSettings =
-    new ElasticsearchConnectionSettings(baseUrl, None, None, List.empty, None)
+    new ElasticsearchConnectionSettings(baseUrl, None, None, List.empty, None, 
false)
 
   /** Java API */
   def create(baseUrl: String): ElasticsearchConnectionSettings =
-    new ElasticsearchConnectionSettings(baseUrl, None, None, List.empty, None)
+    new ElasticsearchConnectionSettings(baseUrl, None, None, List.empty, None, 
false)
 }
diff --git 
a/elasticsearch/src/main/scala/org/apache/pekko/stream/connectors/elasticsearch/impl/ElasticsearchApi.scala
 
b/elasticsearch/src/main/scala/org/apache/pekko/stream/connectors/elasticsearch/impl/ElasticsearchApi.scala
index e29b33ee5..d69ab4cb9 100644
--- 
a/elasticsearch/src/main/scala/org/apache/pekko/stream/connectors/elasticsearch/impl/ElasticsearchApi.scala
+++ 
b/elasticsearch/src/main/scala/org/apache/pekko/stream/connectors/elasticsearch/impl/ElasticsearchApi.scala
@@ -27,26 +27,45 @@ import scala.concurrent.Future
 
   private val logSource = "ElasticsearchApi"
 
+  private def insecureCredentialsMessage(scheme: String): String =
+    ("Credentials are configured but the request URI scheme is '%s' (not 
'https'). " +
+    "Sending BasicAuth credentials over plain HTTP is insecure. " +
+    "Configure a HTTPS base URL to use with credentials, or set " +
+    "withAllowInsecureCredentialsTransport(true) to allow 
this.").format(scheme)
+
+  /**
+   * Decide whether credentials may be sent for this request.
+   *
+   * Returns `Left` with the error message when the request must be rejected,
+   * or `Right` with an optional warning message when it may proceed.
+   */
+  private[impl] def checkCredentialsTransport(
+      scheme: String,
+      connectionSettings: ElasticsearchConnectionSettings): Either[String, 
Option[String]] =
+    if (scheme.toLowerCase == "https") Right(None)
+    else if (connectionSettings.allowInsecureCredentialsTransport) 
Right(Some(insecureCredentialsMessage(scheme)))
+    else Left(insecureCredentialsMessage(scheme))
+
   def executeRequest(
       request: HttpRequest,
       connectionSettings: ElasticsearchConnectionSettings)(implicit http: 
HttpExt): Future[HttpResponse] = {
+    val connectionContext = 
connectionSettings.connectionContext.getOrElse(http.defaultClientHttpsContext)
     if (connectionSettings.hasCredentialsDefined) {
-      val scheme = request.uri.scheme.toLowerCase
-      if (scheme != "https") {
-        val log = Logging(http.system, logSource)
-        val msg =
-          "Credentials are configured but the request URI scheme is '%s' (not 
'https'). " +
-          "Sending BasicAuth credentials over plain HTTP is insecure. " +
-          "Configure a HTTPS base URL to use with credentials.".format(scheme)
-        log.error(msg)
-        throw new IllegalStateException(msg)
+      checkCredentialsTransport(request.uri.scheme, connectionSettings) match {
+        case Left(msg) =>
+          Logging(http.system, logSource).error(msg)
+          // a failed Future rather than a thrown exception, so that the 
calling
+          // stages can route this through their own failure handling
+          Future.failed(new IllegalStateException(msg))
+        case Right(warning) =>
+          warning.foreach(Logging(http.system, logSource).warning(_))
+          http.singleRequest(
+            request.addCredentials(
+              BasicHttpCredentials(connectionSettings.username.get, 
connectionSettings.password.get)),
+            connectionContext = connectionContext)
       }
-      http.singleRequest(
-        
request.addCredentials(BasicHttpCredentials(connectionSettings.username.get, 
connectionSettings.password.get)))
     } else {
-      http.singleRequest(request,
-        connectionContext =
-          
connectionSettings.connectionContext.getOrElse(http.defaultClientHttpsContext))
+      http.singleRequest(request, connectionContext = connectionContext)
     }
   }
 }
diff --git 
a/elasticsearch/src/test/java/docs/javadsl/ElasticsearchParameterizedTest.java 
b/elasticsearch/src/test/java/docs/javadsl/ElasticsearchParameterizedTest.java
index 6f3b7d79d..5df0b82dd 100644
--- 
a/elasticsearch/src/test/java/docs/javadsl/ElasticsearchParameterizedTest.java
+++ 
b/elasticsearch/src/test/java/docs/javadsl/ElasticsearchParameterizedTest.java
@@ -49,7 +49,7 @@ public class ElasticsearchParameterizedTest extends 
ElasticsearchTestBase {
   private void documentation() {
     // #connection-settings
     ElasticsearchConnectionSettings connectionSettings =
-        ElasticsearchConnectionSettings.create("http://localhost:9200";)
+        ElasticsearchConnectionSettings.create("https://localhost:9200";)
             .withCredentials("user", "password");
     // #connection-settings
 
diff --git 
a/elasticsearch/src/test/java/docs/javadsl/OpensearchParameterizedTest.java 
b/elasticsearch/src/test/java/docs/javadsl/OpensearchParameterizedTest.java
index fbf46bd8e..2a46cc02a 100644
--- a/elasticsearch/src/test/java/docs/javadsl/OpensearchParameterizedTest.java
+++ b/elasticsearch/src/test/java/docs/javadsl/OpensearchParameterizedTest.java
@@ -50,7 +50,7 @@ public class OpensearchParameterizedTest extends 
ElasticsearchTestBase {
   private void documentation() {
     // #connection-settings
     ElasticsearchConnectionSettings connectionSettings =
-        OpensearchConnectionSettings.create("http://localhost:9200";)
+        OpensearchConnectionSettings.create("https://localhost:9200";)
             .withCredentials("user", "password");
     // #connection-settings
 
diff --git 
a/elasticsearch/src/test/scala/docs/scaladsl/ElasticsearchConnectorBehaviour.scala
 
b/elasticsearch/src/test/scala/docs/scaladsl/ElasticsearchConnectorBehaviour.scala
index e16e22136..65d368d2f 100644
--- 
a/elasticsearch/src/test/scala/docs/scaladsl/ElasticsearchConnectorBehaviour.scala
+++ 
b/elasticsearch/src/test/scala/docs/scaladsl/ElasticsearchConnectorBehaviour.scala
@@ -667,7 +667,7 @@ trait ElasticsearchConnectorBehaviour {
 
     lazy val _ = {
       // #connection-settings
-      val connectionSettings = 
ElasticsearchConnectionSettings("http://localhost:9200";)
+      val connectionSettings = 
ElasticsearchConnectionSettings("https://localhost:9200";)
         .withCredentials("user", "password")
       // #connection-settings
       // #source-settings
diff --git 
a/elasticsearch/src/test/scala/org/apache/pekko/stream/connectors/elasticsearch/impl/ElasticsearchApiSpec.scala
 
b/elasticsearch/src/test/scala/org/apache/pekko/stream/connectors/elasticsearch/impl/ElasticsearchApiSpec.scala
index 24cb5ee1e..abfef8e67 100644
--- 
a/elasticsearch/src/test/scala/org/apache/pekko/stream/connectors/elasticsearch/impl/ElasticsearchApiSpec.scala
+++ 
b/elasticsearch/src/test/scala/org/apache/pekko/stream/connectors/elasticsearch/impl/ElasticsearchApiSpec.scala
@@ -25,6 +25,7 @@ import pekko.stream.Materializer
 import pekko.stream.connectors.elasticsearch.ElasticsearchConnectionSettings
 import pekko.testkit.TestKit
 import org.scalatest.BeforeAndAfterAll
+import org.scalatest.concurrent.ScalaFutures
 import org.scalatest.matchers.should.Matchers
 import org.scalatest.wordspec.AnyWordSpecLike
 
@@ -32,6 +33,7 @@ class ElasticsearchApiSpec
     extends TestKit(ActorSystem("elasticsearch-api-spec"))
     with AnyWordSpecLike
     with Matchers
+    with ScalaFutures
     with BeforeAndAfterAll {
 
   implicit val mat: Materializer = Materializer(system)
@@ -51,13 +53,65 @@ class ElasticsearchApiSpec
       val request = HttpRequest(HttpMethods.GET)
         .withUri(Uri("http://localhost:9200/_search";))
 
-      val ex = intercept[IllegalStateException] {
-        ElasticsearchApi.executeRequest(request, connectionSettings)
-      }
+      // the rejection arrives as a failed Future, not as a synchronous throw,
+      // so that the calling stages can route it through their failure handling
+      noException should be thrownBy ElasticsearchApi.executeRequest(request, 
connectionSettings)
+
+      val ex = ElasticsearchApi
+        .executeRequest(request, connectionSettings)
+        .failed
+        .futureValue
+      ex shouldBe an[IllegalStateException]
       ex.getMessage should include("not 'https'")
       ex.getMessage should include("insecure")
     }
 
+    "interpolate the actual scheme into the rejection message" in {
+      val connectionSettings =
+        ElasticsearchConnectionSettings("http://localhost:9200";)
+          .withCredentials("user", "pass")
+
+      val request = HttpRequest(HttpMethods.GET)
+        .withUri(Uri("http://localhost:9200/_search";))
+
+      val ex = ElasticsearchApi
+        .executeRequest(request, connectionSettings)
+        .failed
+        .futureValue
+      ex.getMessage should include("scheme is 'http'")
+      (ex.getMessage should not).include("%s")
+    }
+
+  }
+
+  "ElasticsearchApi.checkCredentialsTransport" should {
+
+    val withCredentials =
+      
ElasticsearchConnectionSettings("http://localhost:9200";).withCredentials("user",
 "pass")
+
+    "reject a plain HTTP scheme by default" in {
+      val result = ElasticsearchApi.checkCredentialsTransport("http", 
withCredentials)
+      result.isLeft shouldBe true
+      result.left.toOption.get should include("scheme is 'http'")
+    }
+
+    "accept an HTTPS scheme without warning" in {
+      ElasticsearchApi.checkCredentialsTransport("https", withCredentials) 
shouldBe Right(None)
+    }
+
+    "accept an HTTPS scheme regardless of case" in {
+      ElasticsearchApi.checkCredentialsTransport("HTTPS", withCredentials) 
shouldBe Right(None)
+    }
+
+    "accept a plain HTTP scheme with a warning when insecure transport is 
allowed" in {
+      val settings = 
withCredentials.withAllowInsecureCredentialsTransport(true)
+      val result = ElasticsearchApi.checkCredentialsTransport("http", settings)
+      result.isRight shouldBe true
+      val warning = result.toOption.get
+      warning should be(defined)
+      warning.get should include("insecure")
+    }
+
     "allow HTTPS requests when credentials are configured" in {
       val connectionSettings =
         ElasticsearchConnectionSettings("https://localhost:9200";)


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

Reply via email to