/* * 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 "" + (authzPrincipal != null ? "" : "") + "" + ""; } private static String soap11(String body) { return "" + "" + "" + body + "" + ""; } private static String soap12(String body) { return "" + "" + "" + body + "" + ""; } /** * 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 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(), 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 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 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 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 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 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 params = new LinkedHashMap<>(); params.put("ldap.authzidtypeisid", "true"); Map 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 headers, String body) throws Exception { return doPost(ldapPort, Collections. emptyMap(), headers, body); } private String doPost(int ldapPort, Map extraParams, Map headers, String body) throws Exception { Map 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 list(byte... opTypes) { List 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 receivedOpTypes = new CopyOnWriteArrayList<>(); private final List 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 getReceivedOpTypes() { return new ArrayList<>(receivedOpTypes); } /** The authorization identity of every SASL bind, in the order received. */ List 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 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 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 stub(Class 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; } }