From 46d8dd0ebd9eafa8eda0260182b37afd0105929d Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Tue, 04 Aug 2026 11:49:18 +0000
Subject: [PATCH] [#825] Cap the batchRequest count per SOAP body and the request body size in the DSML gateway (#835)
---
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLServletTestCase.java | 235 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 files changed, 227 insertions(+), 8 deletions(-)
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
index d31a6e5..d68b5f0 100644
--- 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
@@ -18,11 +18,13 @@
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_SEARCH_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 static org.testng.Assert.fail;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
@@ -47,6 +49,7 @@
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletConfig;
import jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.WriteListener;
@@ -59,6 +62,7 @@
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.protocols.ldap.SearchResultDoneProtocolOp;
import org.opends.server.tools.LDAPReader;
import org.opends.server.tools.LDAPWriter;
import org.testng.annotations.Test;
@@ -68,7 +72,9 @@
* 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.
+ * body used to be silently skipped. Also covers the caps on the number of
+ * batchRequest elements per SOAP body (each element costs a bind) and on the
+ * size of the request body.
*/
@SuppressWarnings("javadoc")
@Test(groups = { "precommit", "dsml" })
@@ -96,6 +102,14 @@
private static final String MIXED_AUTHZ_BATCHES =
soap11(abandonBatch("1", "dn:cn=first") + abandonBatch("2", null));
+ /**
+ * A search batch followed by an excess abandon batch: the search produces a
+ * response element, proving that the reply carries the partial results next
+ * to the error rejecting the excess.
+ */
+ private static final String SEARCH_AND_ABANDON_BATCHES =
+ soap11(searchBatch("1") + abandonBatch("2", null));
+
private static String abandonBatch(String requestID, String authzPrincipal)
{
return "<batchRequest xmlns=\"urn:oasis:names:tc:DSML:2:0:core\" requestID=\"" + requestID + "\">"
@@ -104,6 +118,16 @@
+ "</batchRequest>";
}
+ private static String searchBatch(String requestID)
+ {
+ return "<batchRequest xmlns=\"urn:oasis:names:tc:DSML:2:0:core\" requestID=\"" + requestID + "\">"
+ + "<searchRequest dn=\"dc=example,dc=com\" scope=\"baseObject\""
+ + " derefAliases=\"neverDerefAliases\">"
+ + "<filter><present name=\"objectClass\"/></filter>"
+ + "</searchRequest>"
+ + "</batchRequest>";
+ }
+
private static String soap11(String body)
{
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
@@ -249,16 +273,20 @@
/**
* 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.
+ * The cap on batchRequest elements has to be raised to let two of them in.
*/
@Test
public void testEachBatchRequestGetsItsOwnConnection() throws Exception
{
try (FakeLdapServer server = new FakeLdapServer())
{
+ Map<String, String> params = new LinkedHashMap<>();
+ params.put("ldap.dsml.batchrequests.max", "2");
+
Map<String, String> headers = new LinkedHashMap<>();
headers.put("Content-Type", SOAP_1_1_CONTENT_TYPE);
- String response = doPost(server.getPort(), headers, TWO_ABANDON_BATCHES);
+ String response = doPost(server.getPort(), params, headers, TWO_ABANDON_BATCHES);
assertFalse(response.contains("errorResponse"), response);
@@ -271,6 +299,174 @@
}
/**
+ * Each batchRequest element of a SOAP body costs its own connection and
+ * bind, so by default a single POST may only hold one: the excess must be
+ * rejected without being executed, not silently skipped, and the results of
+ * the elements under the cap must still reach the client next to the error.
+ */
+ @Test
+ public void testExcessBatchRequestsAreRejectedByDefault() 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, SEARCH_AND_ABANDON_BATCHES);
+
+ assertTrue(response.contains("searchResponse"), response);
+ assertTrue(response.contains("notAttempted"), response);
+
+ server.awaitDisconnect();
+ assertEquals(server.getReceivedOpTypes(),
+ list(OP_TYPE_BIND_REQUEST, OP_TYPE_SEARCH_REQUEST, OP_TYPE_UNBIND_REQUEST),
+ "only the first batch request may bind under the default cap");
+ }
+ }
+
+ /**
+ * A request whose declared Content-Length exceeds the configured cap is
+ * rejected before the body is read: the LDAP server must never be contacted.
+ */
+ @Test
+ public void testOversizedDeclaredBodyIsRejected() 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(), Collections.<String, String> emptyMap(),
+ headers, ABANDON_BATCH, 20L * 1024 * 1024);
+
+ assertTrue(response.contains("notAttempted"), response);
+ assertTrue(server.getReceivedOpTypes().isEmpty(),
+ "no connection to the directory server should have been opened");
+ }
+ }
+
+ /**
+ * A chunked body declares no length, so the cap has to be enforced while the
+ * body is streamed: the gateway must not buffer more than the configured
+ * maximum, and the LDAP server must never be contacted.
+ */
+ @Test
+ public void testOversizedChunkedBodyIsRejected() throws Exception
+ {
+ try (FakeLdapServer server = new FakeLdapServer())
+ {
+ Map<String, String> params = new LinkedHashMap<>();
+ params.put("ldap.dsml.request.maxsize", "64");
+
+ Map<String, String> headers = new LinkedHashMap<>();
+ headers.put("Content-Type", SOAP_1_1_CONTENT_TYPE);
+
+ String response = doPost(server.getPort(), params, headers, ABANDON_BATCH, -1);
+
+ assertTrue(response.contains("notAttempted"), response);
+ assertTrue(server.getReceivedOpTypes().isEmpty(),
+ "no connection to the directory server should have been opened");
+ }
+ }
+
+ /**
+ * The declared-size check must not add a second error to a reply which
+ * already reports one: the credentials error wins, and the reply holds a
+ * single errorResponse.
+ */
+ @Test
+ public void testOversizedDeclaredBodyDoesNotDoubleACredentialsError() throws Exception
+ {
+ try (FakeLdapServer server = new FakeLdapServer())
+ {
+ Map<String, String> headers = new LinkedHashMap<>();
+ headers.put("Content-Type", SOAP_1_1_CONTENT_TYPE);
+ // 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(), Collections.<String, String> emptyMap(),
+ headers, ABANDON_BATCH, 20L * 1024 * 1024);
+
+ assertTrue(response.contains("authenticationFailed"), response);
+ assertFalse(response.contains("notAttempted"), response);
+ assertTrue(server.getReceivedOpTypes().isEmpty(),
+ "no connection to the directory server should have been opened");
+ }
+ }
+
+ /**
+ * An oversized declared body without a usable Content-Type is rejected on
+ * its size alone: the malformed-request fallback which SAX-parses the whole
+ * body to recover the requestID must not run, so the reply carries a single
+ * error and no requestID.
+ */
+ @Test
+ public void testOversizedDeclaredBodyWithoutContentTypeIsNotParsed() throws Exception
+ {
+ try (FakeLdapServer server = new FakeLdapServer())
+ {
+ String response = doPost(server.getPort(), Collections.<String, String> emptyMap(),
+ new LinkedHashMap<String, String>(), ABANDON_BATCH, 20L * 1024 * 1024);
+
+ assertTrue(response.contains("notAttempted"), response);
+ assertFalse(response.contains("malformedRequest"), response);
+ assertFalse(response.contains("requestID"), response);
+ assertTrue(server.getReceivedOpTypes().isEmpty(),
+ "no connection to the directory server should have been opened");
+ }
+ }
+
+ /** A body of exactly the configured maximum size is accepted: the cap fails only past the limit. */
+ @Test
+ public void testBodyOfExactlyTheMaximumSizeIsAccepted() throws Exception
+ {
+ try (FakeLdapServer server = new FakeLdapServer())
+ {
+ Map<String, String> params = new LinkedHashMap<>();
+ params.put("ldap.dsml.request.maxsize",
+ String.valueOf(ABANDON_BATCH.getBytes(StandardCharsets.UTF_8).length));
+
+ Map<String, String> headers = new LinkedHashMap<>();
+ headers.put("Content-Type", SOAP_1_1_CONTENT_TYPE);
+
+ String response = doPost(server.getPort(), params, headers, ABANDON_BATCH);
+
+ assertFalse(response.contains("errorResponse"), response);
+
+ server.awaitDisconnect();
+ assertEquals(server.getReceivedOpTypes(),
+ list(OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_UNBIND_REQUEST),
+ "a body of exactly the configured maximum must be processed");
+ }
+ }
+
+ /** A cap which is not a positive number must be rejected when the servlet initialises. */
+ @Test
+ public void testNonPositiveCapsAreRejectedAtInit() throws Exception
+ {
+ for (String[] param : new String[][] {
+ { "ldap.dsml.batchrequests.max", "0" },
+ { "ldap.dsml.batchrequests.max", "banana" },
+ { "ldap.dsml.request.maxsize", "-1" } })
+ {
+ Map<String, String> params = new LinkedHashMap<>();
+ params.put("ldap.host", InetAddress.getLoopbackAddress().getHostAddress());
+ params.put("ldap.port", "389");
+ params.put(param[0], param[1]);
+ try
+ {
+ new DSMLServlet().init(servletConfig(params));
+ fail(param[0] + "=" + param[1] + " must be rejected");
+ }
+ catch (ServletException expected)
+ {
+ assertTrue(expected.getMessage().contains(param[0]), expected.getMessage());
+ }
+ }
+ }
+
+ /**
* 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.
@@ -322,6 +518,7 @@
{
Map<String, String> params = new LinkedHashMap<>();
params.put("ldap.authzidtypeisid", "true");
+ params.put("ldap.dsml.batchrequests.max", "2");
Map<String, String> headers = new LinkedHashMap<>();
headers.put("Content-Type", SOAP_1_1_CONTENT_TYPE);
@@ -340,6 +537,17 @@
private String doPost(int ldapPort, Map<String, String> extraParams,
Map<String, String> headers, String body) throws Exception
{
+ return doPost(ldapPort, extraParams, headers, body,
+ body.getBytes(StandardCharsets.UTF_8).length);
+ }
+
+ /**
+ * Same, declaring the given Content-Length: it may differ from the size of
+ * the body, and is -1 for a chunked transfer.
+ */
+ private String doPost(int ldapPort, Map<String, String> extraParams,
+ Map<String, String> headers, String body, long declaredLength) throws Exception
+ {
Map<String, String> params = new LinkedHashMap<>();
params.put("ldap.host", InetAddress.getLoopbackAddress().getHostAddress());
params.put("ldap.port", String.valueOf(ldapPort));
@@ -349,7 +557,9 @@
servlet.init(servletConfig(params));
ByteArrayOutputStream out = new ByteArrayOutputStream();
- servlet.doPost(httpRequest(headers, body.getBytes(StandardCharsets.UTF_8)), httpResponse(out));
+ servlet.doPost(
+ httpRequest(headers, body.getBytes(StandardCharsets.UTF_8), declaredLength),
+ httpResponse(out));
return new String(out.toByteArray(), StandardCharsets.UTF_8);
}
@@ -365,10 +575,10 @@
/**
* 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.
+ * result, answers a search request with an empty 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
{
@@ -477,6 +687,12 @@
writer.writeMessage(new LDAPMessage(message.getMessageID(),
new BindResponseProtocolOp(LDAPResultCode.SUCCESS)));
}
+ else if (message.getProtocolOpType() == OP_TYPE_SEARCH_REQUEST)
+ {
+ // no entries: the search completes with an empty result
+ writer.writeMessage(new LDAPMessage(message.getMessageID(),
+ new SearchResultDoneProtocolOp(LDAPResultCode.SUCCESS)));
+ }
}
}
@@ -530,7 +746,8 @@
"getServletContext".equals(method.getName()) ? context : defaultValue(method));
}
- private static HttpServletRequest httpRequest(final Map<String, String> headers, final byte[] body)
+ private static HttpServletRequest httpRequest(final Map<String, String> headers,
+ final byte[] body, final long declaredLength)
{
final ByteArrayInputStream content = new ByteArrayInputStream(body);
final ServletInputStream in = new ServletInputStream()
@@ -564,6 +781,8 @@
{
case "getInputStream":
return in;
+ case "getContentLengthLong":
+ return declaredLength;
case "getHeaderNames":
return Collections.enumeration(headers.keySet());
case "getHeader":
--
Gitblit v1.10.0