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