From 218108b7e8097ac40db22340263c34c7426548ce Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Mon, 03 Aug 2026 20:51:08 +0000
Subject: [PATCH] [#809] Fix DSML gateway NPE on abandonRequest and on missing Content-Type (#811)

---
 opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLServletTestCase.java    |  655 +++++++++++++++++++++++++++++++++++++++++++
 opendj-server-legacy/src/main/java/org/opends/server/tools/LDAPConnection.java         |    8 
 opendj-server-legacy/src/test/java/org/opends/server/tools/LDAPConnectionTestCase.java |   96 ++++++
 opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLServlet.java            |  124 +++++--
 4 files changed, 839 insertions(+), 44 deletions(-)

diff --git a/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLServlet.java b/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLServlet.java
index 46630f4..8163dba 100644
--- a/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLServlet.java
+++ b/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLServlet.java
@@ -42,8 +42,6 @@
 import java.util.StringTokenizer;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
-import java.util.logging.Level;
-import java.util.logging.Logger;
 
 import jakarta.servlet.ServletConfig;
 import jakarta.servlet.ServletException;
@@ -106,6 +104,12 @@
  * It parses the SOAP request, calls the appropriate class
  * which performs the LDAP operation, and returns the response
  * as a DSML response.
+ * <p>
+ * Everything is logged through {@code getServletContext().log()}: it is the
+ * only sink which survives at runtime, as
+ * {@code LDAPConnection.connectToHost()} turns {@code java.util.logging} off
+ * for the whole JVM on every non-verbose connection, and the war ships
+ * {@code slf4j-api} without any provider.
  */
 public class DSMLServlet extends HttpServlet {
   private static final String PKG_NAME = "org.opends.dsml.protocol";
@@ -165,6 +169,9 @@
    */
   @Override
   public void init(ServletConfig config) throws ServletException {
+    // Let GenericServlet keep the configuration: getServletContext() relies on
+    // it, and it is the only logging facility available at runtime.
+    super.init(config);
     try {
       hostName = stringValue(config, HOST);
       port = Integer.valueOf(stringValue(config, PORT));
@@ -333,7 +340,6 @@
     connOptions.setUseSSL(useSSL);
     connOptions.setStartTLS(useStartTLS);
 
-    LDAPConnection connection = null;
     BatchRequest batchRequest = null;
 
     // Keep the Servlet input stream buffered in case the SOAP un-marshalling
@@ -400,9 +406,8 @@
             messageFactory = MessageFactory.newInstance(SOAPConstants.SOAP_1_2_PROTOCOL);
             messageContentType = SOAPConstants.SOAP_1_2_CONTENT_TYPE;
           }
-          else {
-            throw new ServletException("Content-Type does not match SOAP 1.1 or SOAP 1.2");
-          }
+          // An unsupported Content-Type leaves the message factory unset: the
+          // request is rejected as malformed once all the headers are read.
         }
         catch (SOAPException e)
         {
@@ -430,12 +435,14 @@
             bindPassword = unencoded.substring(colon + 1);
           }
         } catch (final LocalizedIllegalArgumentException ex) {
-          // user/DN:password parsing error
+          // user/DN:password parsing error. Keep reading the headers: the
+          // Content-Type may still be ahead, and it decides which SOAP
+          // version the error is reported with.
           batchResponses.add(
             createErrorResponse(objFactory,
                   new LDAPException(LDAPResultCode.INVALID_CREDENTIALS,
                   LocalizableMessage.raw(ex.getMessage()))));
-          break;
+          continue;
         }
       }
       StringTokenizer tk = new StringTokenizer(headerVal, ",");
@@ -477,6 +484,31 @@
       }
     }
 
+    if ( messageFactory == null ) {
+      // The request carries no Content-Type header, or one which matches
+      // neither SOAP 1.1 nor SOAP 1.2: it cannot be parsed. Fall back to
+      // SOAP 1.1 for the response and reject the request as malformed,
+      // unless an error has already been reported.
+      try
+      {
+        messageFactory = MessageFactory.newInstance(SOAPConstants.SOAP_1_1_PROTOCOL);
+        messageContentType = SOAPConstants.SOAP_1_1_CONTENT_TYPE;
+      }
+      catch (SOAPException e)
+      {
+        throw new ServletException(e.getMessage());
+      }
+      if ( batchResponses.isEmpty() ) {
+        // Nothing has been read from the stream yet, so the SAX pass can still
+        // recover the requestID and let the client correlate the reply.
+        batchResponses.add(
+            createXMLParsingErrorResponse(is,
+                                          objFactory,
+                                          batchResponse,
+                                          "Content-Type does not match SOAP 1.1 or SOAP 1.2"));
+      }
+    }
+
     // if an error already occurred, the list is not empty
     if ( batchResponses.isEmpty() ) {
       try {
@@ -520,6 +552,13 @@
           boolean authzInControl = false;
           batchRequest = batchRequestElement.getValue();
 
+          // The connection options are shared by all the batch requests of this
+          // SOAP body, so the authzid of the previous one must not survive into
+          // the bind of this one: it would run under an authorization identity
+          // it never asked for, and addSASLProperty() appends to the values of
+          // a key, which SASL PLAIN rejects as a multi-valued authzid.
+          connOptions.getSASLProperties().remove("authzid");
+
           /*
            *  Process optional authRequest (i.e. use authz)
            */
@@ -541,10 +580,12 @@
 
           boolean connected = false;
 
-          if ( connection == null ) {
-            connection = new LDAPConnection(hostName, port, connOptions);
+          // Each batch request gets its own connection: the previous one has
+          // been closed by the finally block below.
+          LDAPConnection connection =
+              new LDAPConnection(hostName, port, connOptions);
+          try {
             try {
-
               connection.connectToHost(bindDN, bindPassword);
               if (authzInControl)
               {
@@ -565,35 +606,37 @@
               // if connection failed, return appropriate error response
               batchResponses.add(createErrorResponse(objFactory, e));
             }
-          }
-          if ( connected ) {
-            List<DsmlMessage> list = batchRequest.getBatchRequests();
+            if ( connected ) {
+              List<DsmlMessage> list = batchRequest.getBatchRequests();
 
-            for (DsmlMessage request : list) {
-              JAXBElement<?> result = performLDAPRequest(connection, objFactory, proxyAuthzControl, request);
-              if ( result != null ) {
-                batchResponses.add(result);
-              }
-              // evaluate response to check if an error occurred
-              Object o = result.getValue();
-              if ( o instanceof ErrorResponse ) {
-                if ( ON_ERROR_EXIT.equals(batchRequest.getOnError()) ) {
-                  break;
+              for (DsmlMessage request : list) {
+                JAXBElement<?> result = performLDAPRequest(connection, objFactory, proxyAuthzControl, request);
+                if ( result == null ) {
+                  // an abandon request does not produce any response element
+                  continue;
                 }
-              } else if ( o instanceof LDAPResult ) {
-                int code = ((LDAPResult)o).getResultCode().getCode();
-                if ( code != LDAPResultCode.SUCCESS
-                  && code != LDAPResultCode.REFERRAL
-                  && code != LDAPResultCode.COMPARE_TRUE
-                  && code != LDAPResultCode.COMPARE_FALSE && ON_ERROR_EXIT.equals(batchRequest.getOnError()) )
-                {
-                  break;
+                batchResponses.add(result);
+                // evaluate response to check if an error occurred
+                Object o = result.getValue();
+                if ( o instanceof ErrorResponse ) {
+                  if ( ON_ERROR_EXIT.equals(batchRequest.getOnError()) ) {
+                    break;
+                  }
+                } else if ( o instanceof LDAPResult ) {
+                  int code = ((LDAPResult)o).getResultCode().getCode();
+                  if ( code != LDAPResultCode.SUCCESS
+                    && code != LDAPResultCode.REFERRAL
+                    && code != LDAPResultCode.COMPARE_TRUE
+                    && code != LDAPResultCode.COMPARE_FALSE && ON_ERROR_EXIT.equals(batchRequest.getOnError()) )
+                  {
+                    break;
+                  }
                 }
               }
             }
-          }
-          // close connection to LDAP server
-          if ( connection != null ) {
+          } finally {
+            // close connection to LDAP server, whatever happened while
+            // processing the batch
             connection.close(nextMessageID);
           }
         }
@@ -604,7 +647,8 @@
       marshaller.marshal(objFactory.createBatchResponse(batchResponse), doc);
       sendResponse(doc, messageFactory, messageContentType, res);
     } catch (Exception e) {
-      e.printStackTrace();
+      // The client gets an empty response: at least make the cause visible.
+      getServletContext().log("Unable to send the DSML response", e);
     }
 
   }
@@ -628,14 +672,14 @@
     {
       if (logFeatureWarnings.compareAndSet(false, true))
       {
-        Logger.getLogger(PKG_NAME).log(Level.SEVERE, "XMLReader unsupported feature " + feature);
+        getServletContext().log("XMLReader unsupported feature " + feature);
       }
     }
     catch (SAXNotRecognizedException e)
     {
       if (logFeatureWarnings.compareAndSet(false, true))
       {
-        Logger.getLogger(PKG_NAME).log(Level.SEVERE, "XMLReader unrecognized feature " + feature);
+        getServletContext().log("XMLReader unrecognized feature " + feature);
       }
     }
   }
@@ -893,7 +937,7 @@
     catch (ParserConfigurationException e) {
       if (logFeatureWarnings.compareAndSet(false, true))
       {
-        Logger.getLogger(PKG_NAME).log(Level.SEVERE, "DocumentBuilderFactory unsupported feature " + feature);
+        getServletContext().log("DocumentBuilderFactory unsupported feature " + feature);
       }
     }
   }
@@ -916,7 +960,7 @@
     catch (ParserConfigurationException e)
     {
       if (logFeatureWarnings.compareAndSet(false, true)) {
-        Logger.getLogger(PKG_NAME).log(Level.SEVERE, "DocumentBuilderFactory cannot be configured securely");
+        getServletContext().log("DocumentBuilderFactory cannot be configured securely");
       }
     }
     dbf.setXIncludeAware(false);
diff --git a/opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLServletTestCase.java b/opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLServletTestCase.java
new file mode 100644
index 0000000..d31a6e5
--- /dev/null
+++ b/opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLServletTestCase.java
@@ -0,0 +1,655 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package org.opends.dsml.protocol;
+
+import static java.util.Arrays.asList;
+import static org.opends.server.protocols.ldap.LDAPConstants.OP_TYPE_ABANDON_REQUEST;
+import static org.opends.server.protocols.ldap.LDAPConstants.OP_TYPE_BIND_REQUEST;
+import static org.opends.server.protocols.ldap.LDAPConstants.OP_TYPE_UNBIND_REQUEST;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.Closeable;
+import java.io.IOException;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+
+import jakarta.servlet.ReadListener;
+import jakarta.servlet.ServletConfig;
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletInputStream;
+import jakarta.servlet.ServletOutputStream;
+import jakarta.servlet.WriteListener;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.forgerock.opendj.ldap.ByteString;
+import org.forgerock.testng.ForgeRockTestCase;
+import org.opends.server.protocols.ldap.BindRequestProtocolOp;
+import org.opends.server.protocols.ldap.BindResponseProtocolOp;
+import org.opends.server.protocols.ldap.LDAPMessage;
+import org.opends.server.protocols.ldap.LDAPResultCode;
+import org.opends.server.tools.LDAPReader;
+import org.opends.server.tools.LDAPWriter;
+import org.testng.annotations.Test;
+
+/**
+ * Tests the error handling of {@link DSMLServlet#doPost}: an abandon request
+ * used to trigger a {@code NullPointerException} which leaked the LDAP
+ * connection, a request without a usable Content-Type header used to trigger a
+ * {@code NullPointerException} as well, and the second batch request of a SOAP
+ * body used to be silently skipped.
+ */
+@SuppressWarnings("javadoc")
+@Test(groups = { "precommit", "dsml" })
+public class DSMLServletTestCase extends ForgeRockTestCase
+{
+  /** SOAP 1.1 content type. */
+  private static final String SOAP_1_1_CONTENT_TYPE = "text/xml";
+  /** SOAP 1.2 content type. */
+  private static final String SOAP_1_2_CONTENT_TYPE = "application/soap+xml";
+  /** SOAP 1.2 envelope namespace, as it appears in the reply. */
+  private static final String SOAP_1_2_NAMESPACE = "http://www.w3.org/2003/05/soap-envelope";
+
+  private static final String ABANDON_BATCH =
+      soap11(abandonBatch("1", null));
+
+  /** Two batch requests in a single SOAP body, each carrying an abandon request. */
+  private static final String TWO_ABANDON_BATCHES =
+      soap11(abandonBatch("1", null) + abandonBatch("2", null));
+
+  /** Same, with an authRequest which turns into a SASL authzid on each bind. */
+  private static final String TWO_AUTHZ_BATCHES =
+      soap11(abandonBatch("1", "dn:cn=first") + abandonBatch("2", "dn:cn=second"));
+
+  /** Same, but only the first batch request asks for an authorization identity. */
+  private static final String MIXED_AUTHZ_BATCHES =
+      soap11(abandonBatch("1", "dn:cn=first") + abandonBatch("2", null));
+
+  private static String abandonBatch(String requestID, String authzPrincipal)
+  {
+    return "<batchRequest xmlns=\"urn:oasis:names:tc:DSML:2:0:core\" requestID=\"" + requestID + "\">"
+        + (authzPrincipal != null ? "<authRequest principal=\"" + authzPrincipal + "\"/>" : "")
+        + "<abandonRequest abandonID=\"1\"/>"
+        + "</batchRequest>";
+  }
+
+  private static String soap11(String body)
+  {
+    return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+        + "<se:Envelope xmlns:se=\"http://schemas.xmlsoap.org/soap/envelope/\">"
+        + "<se:Body>" + body + "</se:Body>"
+        + "</se:Envelope>";
+  }
+
+  private static String soap12(String body)
+  {
+    return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+        + "<se:Envelope xmlns:se=\"" + SOAP_1_2_NAMESPACE + "\">"
+        + "<se:Body>" + body + "</se:Body>"
+        + "</se:Envelope>";
+  }
+
+  /**
+   * An abandon request produces no response element: the servlet must neither
+   * fail nor leave the connection to the directory server open.
+   */
+  @Test
+  public void testAbandonRequestIsProcessedAndConnectionIsClosed() throws Exception
+  {
+    try (FakeLdapServer server = new FakeLdapServer())
+    {
+      Map<String, String> headers = new LinkedHashMap<>();
+      headers.put("Content-Type", SOAP_1_1_CONTENT_TYPE);
+
+      String response = doPost(server.getPort(), headers, ABANDON_BATCH);
+
+      assertTrue(response.contains("batchResponse"), response);
+      assertFalse(response.contains("errorResponse"), response);
+      // no response element is defined for an abandon request
+      assertFalse(response.contains("abandonResponse"), response);
+
+      server.awaitDisconnect();
+      assertEquals(server.getReceivedOpTypes(),
+          list(OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST),
+          "the abandon request was not forwarded, or the connection was leaked");
+    }
+  }
+
+  /**
+   * A request without any Content-Type header must be rejected as malformed,
+   * keeping the requestID so that the client can correlate the reply.
+   */
+  @Test
+  public void testMissingContentTypeIsRejectedAsMalformedRequest() throws Exception
+  {
+    try (FakeLdapServer server = new FakeLdapServer())
+    {
+      String response = doPost(server.getPort(), new LinkedHashMap<String, String>(), ABANDON_BATCH);
+
+      assertTrue(response.contains("malformedRequest"), response);
+      assertTrue(response.contains("requestID=\"1\""), response);
+      assertTrue(server.getReceivedOpTypes().isEmpty(),
+          "no connection to the directory server should have been opened");
+    }
+  }
+
+  /** A Content-Type header matching neither SOAP 1.1 nor SOAP 1.2 is malformed too. */
+  @Test
+  public void testUnsupportedContentTypeIsRejectedAsMalformedRequest() throws Exception
+  {
+    try (FakeLdapServer server = new FakeLdapServer())
+    {
+      Map<String, String> headers = new LinkedHashMap<>();
+      headers.put("Content-Type", "application/json");
+
+      String response = doPost(server.getPort(), headers, ABANDON_BATCH);
+
+      assertTrue(response.contains("malformedRequest"), response);
+      assertTrue(server.getReceivedOpTypes().isEmpty(),
+          "no connection to the directory server should have been opened");
+    }
+  }
+
+  /**
+   * An error detected before the request is parsed must still reach the client
+   * when the Content-Type header is missing.
+   */
+  @Test
+  public void testMissingContentTypeStillReportsCredentialsError() throws Exception
+  {
+    try (FakeLdapServer server = new FakeLdapServer())
+    {
+      Map<String, String> headers = new LinkedHashMap<>();
+      // credentials without the ':' separator: the password cannot be retrieved
+      headers.put("Authorization", "Basic " + Base64.getEncoder()
+          .encodeToString("cn=directory manager".getBytes(StandardCharsets.UTF_8)));
+
+      String response = doPost(server.getPort(), headers, ABANDON_BATCH);
+
+      assertTrue(response.contains("authenticationFailed"), response);
+      assertTrue(server.getReceivedOpTypes().isEmpty(),
+          "no connection to the directory server should have been opened");
+    }
+  }
+
+  /**
+   * A malformed Authorization header must not stop the header scan: the
+   * Content-Type still decides which SOAP version the error is reported with.
+   */
+  @Test
+  public void testMalformedAuthorizationKeepsTheRequestSoapVersion() throws Exception
+  {
+    try (FakeLdapServer server = new FakeLdapServer())
+    {
+      Map<String, String> headers = new LinkedHashMap<>();
+      // credentials which are not valid Base64, read before the Content-Type
+      headers.put("Authorization", "Basic !!!");
+      headers.put("Content-Type", SOAP_1_2_CONTENT_TYPE);
+
+      String response = doPost(server.getPort(), headers, soap12(abandonBatch("1", null)));
+
+      assertTrue(response.contains("authenticationFailed"), response);
+      assertTrue(response.contains(SOAP_1_2_NAMESPACE), response);
+    }
+  }
+
+  /** The SOAP 1.2 request path must work as the SOAP 1.1 one does. */
+  @Test
+  public void testSoap12RequestIsProcessed() throws Exception
+  {
+    try (FakeLdapServer server = new FakeLdapServer())
+    {
+      Map<String, String> headers = new LinkedHashMap<>();
+      headers.put("Content-Type", SOAP_1_2_CONTENT_TYPE);
+
+      String response = doPost(server.getPort(), headers, soap12(abandonBatch("1", null)));
+
+      assertTrue(response.contains("batchResponse"), response);
+      assertFalse(response.contains("errorResponse"), response);
+      assertTrue(response.contains(SOAP_1_2_NAMESPACE), response);
+
+      server.awaitDisconnect();
+      assertEquals(server.getReceivedOpTypes(),
+          list(OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST),
+          "the abandon request was not forwarded, or the connection was leaked");
+    }
+  }
+
+  /**
+   * Every batch request of a SOAP body gets its own connection: the second one
+   * used to be silently skipped because the first connection was left assigned.
+   */
+  @Test
+  public void testEachBatchRequestGetsItsOwnConnection() throws Exception
+  {
+    try (FakeLdapServer server = new FakeLdapServer())
+    {
+      Map<String, String> headers = new LinkedHashMap<>();
+      headers.put("Content-Type", SOAP_1_1_CONTENT_TYPE);
+
+      String response = doPost(server.getPort(), headers, TWO_ABANDON_BATCHES);
+
+      assertFalse(response.contains("errorResponse"), response);
+
+      server.awaitDisconnect(2);
+      assertEquals(server.getReceivedOpTypes(),
+          list(OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST,
+               OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST),
+          "the second batch request was not processed on its own connection");
+    }
+  }
+
+  /**
+   * The connection options are shared by all the batch requests of a SOAP body,
+   * and the SASL authzid they carry is single valued: the authzid of a batch
+   * request must not survive into the bind of the next one.
+   */
+  @Test
+  public void testAuthzIdIsNotAccumulatedAcrossBatchRequests() throws Exception
+  {
+    try (FakeLdapServer server = new FakeLdapServer())
+    {
+      String response = doAuthzPost(server, TWO_AUTHZ_BATCHES);
+
+      assertFalse(response.contains("errorResponse"), response);
+
+      server.awaitDisconnect(2);
+      assertEquals(server.getReceivedOpTypes(),
+          list(OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST,
+               OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST),
+          "the bind of the second batch request did not happen");
+      assertEquals(server.getReceivedAuthzIds(), asList("dn:cn=first", "dn:cn=second"),
+          "each batch request must bind under the authzid of its own authRequest");
+    }
+  }
+
+  /**
+   * A batch request which carries no authRequest must not inherit the
+   * authorization identity of the previous one: the shared connection options
+   * have to be cleared whether or not this batch request sets an authzid.
+   */
+  @Test
+  public void testAuthzIdDoesNotSurviveIntoBatchRequestWithoutAuthRequest() throws Exception
+  {
+    try (FakeLdapServer server = new FakeLdapServer())
+    {
+      String response = doAuthzPost(server, MIXED_AUTHZ_BATCHES);
+
+      assertFalse(response.contains("errorResponse"), response);
+
+      server.awaitDisconnect(2);
+      assertEquals(server.getReceivedAuthzIds(), asList("dn:cn=first", ""),
+          "the second batch request ran under the authorization identity of the first one");
+    }
+  }
+
+  /**
+   * Posts the given SOAP body with HTTP credentials turned into a SASL PLAIN
+   * authid, so that the authRequest of a batch request becomes an authzid.
+   */
+  private String doAuthzPost(FakeLdapServer server, String body) throws Exception
+  {
+    Map<String, String> params = new LinkedHashMap<>();
+    params.put("ldap.authzidtypeisid", "true");
+
+    Map<String, String> headers = new LinkedHashMap<>();
+    headers.put("Content-Type", SOAP_1_1_CONTENT_TYPE);
+    headers.put("Authorization", "Basic " + Base64.getEncoder()
+        .encodeToString("user:password".getBytes(StandardCharsets.UTF_8)));
+
+    return doPost(server.getPort(), params, headers, body);
+  }
+
+  /** Runs {@code doPost} against a servlet configured to use the given LDAP port. */
+  private String doPost(int ldapPort, Map<String, String> headers, String body) throws Exception
+  {
+    return doPost(ldapPort, Collections.<String, String> emptyMap(), headers, body);
+  }
+
+  private String doPost(int ldapPort, Map<String, String> extraParams,
+      Map<String, String> headers, String body) throws Exception
+  {
+    Map<String, String> params = new LinkedHashMap<>();
+    params.put("ldap.host", InetAddress.getLoopbackAddress().getHostAddress());
+    params.put("ldap.port", String.valueOf(ldapPort));
+    params.putAll(extraParams);
+
+    DSMLServlet servlet = new DSMLServlet();
+    servlet.init(servletConfig(params));
+
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    servlet.doPost(httpRequest(headers, body.getBytes(StandardCharsets.UTF_8)), httpResponse(out));
+    return new String(out.toByteArray(), StandardCharsets.UTF_8);
+  }
+
+  private static List<Byte> list(byte... opTypes)
+  {
+    List<Byte> result = new ArrayList<>(opTypes.length);
+    for (byte opType : opTypes)
+    {
+      result.add(opType);
+    }
+    return result;
+  }
+
+  /**
+   * A minimal LDAP endpoint which answers the bind request with a success
+   * result and records the type of every message it receives, as well as the
+   * authorization identity of every SASL bind. Connections are served one after
+   * the other, so that a SOAP body holding several batch requests can be
+   * exercised.
+   */
+  private static final class FakeLdapServer implements Closeable
+  {
+    private final ServerSocket serverSocket;
+    private final List<Byte> receivedOpTypes = new CopyOnWriteArrayList<>();
+    private final List<String> receivedAuthzIds = new CopyOnWriteArrayList<>();
+    private final Object lock = new Object();
+    private int closedConnections;
+    private volatile Exception failure;
+    private volatile boolean stopped;
+
+    FakeLdapServer() throws IOException
+    {
+      serverSocket = new ServerSocket(0, 16, InetAddress.getLoopbackAddress());
+      Thread thread = new Thread(this::serve, "fake-ldap-server");
+      thread.setDaemon(true);
+      thread.start();
+    }
+
+    int getPort()
+    {
+      return serverSocket.getLocalPort();
+    }
+
+    List<Byte> getReceivedOpTypes()
+    {
+      return new ArrayList<>(receivedOpTypes);
+    }
+
+    /** The authorization identity of every SASL bind, in the order received. */
+    List<String> getReceivedAuthzIds()
+    {
+      return new ArrayList<>(receivedAuthzIds);
+    }
+
+    void awaitDisconnect() throws InterruptedException
+    {
+      awaitDisconnect(1);
+    }
+
+    /** Waits for the given number of connections to have been served. */
+    void awaitDisconnect(int expectedConnections) throws InterruptedException
+    {
+      final long deadline = System.currentTimeMillis()
+          + TimeUnit.SECONDS.toMillis(30);
+      synchronized (lock)
+      {
+        while (closedConnections < expectedConnections)
+        {
+          final long remaining = deadline - System.currentTimeMillis();
+          assertTrue(remaining > 0, "the client did not disconnect: "
+              + closedConnections + " connection(s) served out of " + expectedConnections);
+          lock.wait(remaining);
+        }
+      }
+      assertNull(failure, "the fake LDAP server failed: " + failure);
+    }
+
+    private void serve()
+    {
+      while (!stopped)
+      {
+        final Socket socket;
+        try
+        {
+          socket = serverSocket.accept();
+        }
+        catch (IOException e)
+        {
+          if (!stopped)
+          {
+            recordFailure(e);
+          }
+          return;
+        }
+        try (Socket connection = socket)
+        {
+          serveConnection(connection);
+        }
+        catch (Exception e)
+        {
+          recordFailure(e);
+        }
+        finally
+        {
+          synchronized (lock)
+          {
+            closedConnections++;
+            lock.notifyAll();
+          }
+        }
+      }
+    }
+
+    private void serveConnection(Socket socket) throws Exception
+    {
+      LDAPReader reader = new LDAPReader(socket);
+      LDAPWriter writer = new LDAPWriter(socket);
+      LDAPMessage message;
+      while ((message = reader.readMessage()) != null)
+      {
+        receivedOpTypes.add(message.getProtocolOpType());
+        if (message.getProtocolOpType() == OP_TYPE_BIND_REQUEST)
+        {
+          recordAuthzId(message.getBindRequestProtocolOp());
+          writer.writeMessage(new LDAPMessage(message.getMessageID(),
+              new BindResponseProtocolOp(LDAPResultCode.SUCCESS)));
+        }
+      }
+    }
+
+    /**
+     * Records the authorization identity of a SASL bind. The credentials of
+     * SASL PLAIN are "authzid NUL authid NUL password", with an empty authzid
+     * when the client asked for none.
+     */
+    private void recordAuthzId(BindRequestProtocolOp bindRequest)
+    {
+      ByteString credentials = bindRequest.getSASLCredentials();
+      if (credentials == null)
+      {
+        return;
+      }
+      String plain = credentials.toString();
+      int separator = plain.indexOf('\0');
+      receivedAuthzIds.add(separator >= 0 ? plain.substring(0, separator) : plain);
+    }
+
+    private void recordFailure(Exception e)
+    {
+      if (!stopped && failure == null)
+      {
+        failure = e;
+      }
+    }
+
+    @Override
+    public void close() throws IOException
+    {
+      stopped = true;
+      serverSocket.close();
+    }
+  }
+
+  private static ServletConfig servletConfig(final Map<String, String> params)
+  {
+    final ServletContext context = stub(ServletContext.class, (proxy, method, args) -> {
+      switch (method.getName())
+      {
+      case "getInitParameter":
+        return params.get(args[0]);
+      case "getInitParameterNames":
+        return Collections.enumeration(params.keySet());
+      default:
+        return defaultValue(method);
+      }
+    });
+    return stub(ServletConfig.class, (proxy, method, args) ->
+        "getServletContext".equals(method.getName()) ? context : defaultValue(method));
+  }
+
+  private static HttpServletRequest httpRequest(final Map<String, String> headers, final byte[] body)
+  {
+    final ByteArrayInputStream content = new ByteArrayInputStream(body);
+    final ServletInputStream in = new ServletInputStream()
+    {
+      @Override
+      public int read()
+      {
+        return content.read();
+      }
+
+      @Override
+      public boolean isFinished()
+      {
+        return content.available() == 0;
+      }
+
+      @Override
+      public boolean isReady()
+      {
+        return true;
+      }
+
+      @Override
+      public void setReadListener(ReadListener readListener)
+      {
+        // not used
+      }
+    };
+    return stub(HttpServletRequest.class, (proxy, method, args) -> {
+      switch (method.getName())
+      {
+      case "getInputStream":
+        return in;
+      case "getHeaderNames":
+        return Collections.enumeration(headers.keySet());
+      case "getHeader":
+        return headers.get(args[0]);
+      default:
+        return defaultValue(method);
+      }
+    });
+  }
+
+  private static HttpServletResponse httpResponse(final ByteArrayOutputStream out)
+  {
+    final ServletOutputStream os = new ServletOutputStream()
+    {
+      @Override
+      public void write(int b)
+      {
+        out.write(b);
+      }
+
+      @Override
+      public boolean isReady()
+      {
+        return true;
+      }
+
+      @Override
+      public void setWriteListener(WriteListener writeListener)
+      {
+        // not used
+      }
+    };
+    return stub(HttpServletResponse.class, (proxy, method, args) ->
+        "getOutputStream".equals(method.getName()) ? os : defaultValue(method));
+  }
+
+  private static <T> T stub(Class<T> type, InvocationHandler handler)
+  {
+    return type.cast(Proxy.newProxyInstance(
+        DSMLServletTestCase.class.getClassLoader(), new Class<?>[] { type }, handler));
+  }
+
+  /**
+   * A proxy must return a value assignable to the return type of the invoked
+   * method: {@code null} is only acceptable for a reference or {@code void}
+   * return type, so every primitive has to be covered here.
+   */
+  private static Object defaultValue(Method method)
+  {
+    Class<?> returnType = method.getReturnType();
+    if (returnType == boolean.class)
+    {
+      return Boolean.FALSE;
+    }
+    else if (returnType == char.class)
+    {
+      return (char) 0;
+    }
+    else if (returnType == byte.class)
+    {
+      return (byte) 0;
+    }
+    else if (returnType == short.class)
+    {
+      return (short) 0;
+    }
+    else if (returnType == int.class)
+    {
+      return 0;
+    }
+    else if (returnType == long.class)
+    {
+      return 0L;
+    }
+    else if (returnType == float.class)
+    {
+      return 0f;
+    }
+    else if (returnType == double.class)
+    {
+      return 0d;
+    }
+    else if ("toString".equals(method.getName()))
+    {
+      return "stub";
+    }
+    return null;
+  }
+}
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/tools/LDAPConnection.java b/opendj-server-legacy/src/main/java/org/opends/server/tools/LDAPConnection.java
index f0eef29..9abe7e7 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/tools/LDAPConnection.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/tools/LDAPConnection.java
@@ -443,10 +443,10 @@
       {
         try
         {
-        	final Socket s=new Socket();
-        	s.setReuseAddress(true);
-        	s.bind( new InetSocketAddress(inetAddress, portNumber));
-        	return s;
+          final Socket s = new Socket();
+          s.setReuseAddress(true);
+          s.connect(new InetSocketAddress(inetAddress, portNumber));
+          return s;
         }
         catch (ConnectException ce2)
         {
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/tools/LDAPConnectionTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/tools/LDAPConnectionTestCase.java
new file mode 100644
index 0000000..b739fd4
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/tools/LDAPConnectionTestCase.java
@@ -0,0 +1,96 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package org.opends.server.tools;
+
+import static org.opends.server.protocols.ldap.LDAPResultCode.CLIENT_SIDE_CONNECT_ERROR;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.fail;
+
+import java.net.InetAddress;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.opends.server.DirectoryServerTestCase;
+import org.opends.server.TestCaseUtils;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+/**
+ * Tests the plain (neither SSL nor StartTLS) connection path of
+ * {@link LDAPConnection}, which is the one used by the DSML gateway and by the
+ * tools built on {@code LDAPConnectionArgumentParser}. Those are the only
+ * callers reaching {@code createSocket()}: a caller which installs an
+ * {@code SSLConnectionFactory} goes through {@code createSSLSocket()} instead.
+ */
+@SuppressWarnings("javadoc")
+@Test(groups = { "precommit", "tools" }, sequential = true)
+public class LDAPConnectionTestCase extends DirectoryServerTestCase
+{
+  @BeforeClass
+  public void startServer() throws Exception
+  {
+    TestCaseUtils.startServer();
+  }
+
+  /**
+   * The socket must be connected to the directory server: binding it to the
+   * server address instead makes every plain connection fail with
+   * "Address already in use".
+   */
+  @Test
+  public void testConnectToHostConnectsThePlainSocket() throws Exception
+  {
+    LDAPConnection connection = new LDAPConnection(
+        InetAddress.getLoopbackAddress().getHostAddress(),
+        TestCaseUtils.getServerLdapPort(), new LDAPConnectionOptions());
+    try
+    {
+      connection.connectToHost("cn=Directory Manager", "password");
+      assertNotNull(connection.getLDAPReader(), "the connection was not established");
+      assertNotNull(connection.getLDAPWriter(), "the connection was not established");
+    }
+    finally
+    {
+      connection.close(new AtomicInteger(1));
+    }
+  }
+
+  /**
+   * A port with nothing behind it must be reported as a connect error: it is
+   * the {@code ConnectException} of each candidate address which drives the
+   * failover of {@code createSocket()}.
+   */
+  @Test
+  public void testConnectToClosedPortIsAConnectError() throws Exception
+  {
+    LDAPConnection connection = new LDAPConnection(
+        InetAddress.getLoopbackAddress().getHostAddress(),
+        TestCaseUtils.findFreePort(), new LDAPConnectionOptions());
+    try
+    {
+      connection.connectToHost("cn=Directory Manager", "password");
+      fail("connecting to a closed port should have failed");
+    }
+    catch (LDAPConnectionException e)
+    {
+      assertEquals(e.getResultCode(), CLIENT_SIDE_CONNECT_ERROR, String.valueOf(e));
+    }
+    finally
+    {
+      connection.close(new AtomicInteger(1));
+    }
+  }
+}

--
Gitblit v1.10.0