mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valery Kharseko
16 hours ago 218108b7e8097ac40db22340263c34c7426548ce
[#809] Fix DSML gateway NPE on abandonRequest and on missing Content-Type (#811)
2 files modified
2 files added
839 ■■■■■ changed files
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLServlet.java 86 ●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLServletTestCase.java 655 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/tools/LDAPConnection.java 2 ●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/tools/LDAPConnectionTestCase.java 96 ●●●●● patch | view | raw | blame | history
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,15 +606,16 @@
              // if connection failed, return appropriate error response
              batchResponses.add(createErrorResponse(objFactory, e));
            }
          }
          if ( connected ) {
            List<DsmlMessage> list = batchRequest.getBatchRequests();
            for (DsmlMessage request : list) {
              JAXBElement<?> result = performLDAPRequest(connection, objFactory, proxyAuthzControl, request);
              if ( result != null ) {
                batchResponses.add(result);
                if ( result == null ) {
                  // an abandon request does not produce any response element
                  continue;
              }
                batchResponses.add(result);
              // evaluate response to check if an error occurred
              Object o = result.getValue();
              if ( o instanceof ErrorResponse ) {
@@ -592,8 +634,9 @@
              }
            }
          }
          // 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);
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLServletTestCase.java
New file
@@ -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;
  }
}
opendj-server-legacy/src/main/java/org/opends/server/tools/LDAPConnection.java
@@ -445,7 +445,7 @@
        {
            final Socket s=new Socket();
            s.setReuseAddress(true);
            s.bind( new InetSocketAddress(inetAddress, portNumber));
          s.connect(new InetSocketAddress(inetAddress, portNumber));
            return s;
        }
        catch (ConnectException ce2)
opendj-server-legacy/src/test/java/org/opends/server/tools/LDAPConnectionTestCase.java
New file
@@ -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));
    }
  }
}