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