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 +++++++++++++
 opendj-dsml-servlet/resources/webapp/web.xml                                        |   21 +
 opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLServlet.java         |  724 ++++++++++++++++++++++++++----------------
 3 files changed, 690 insertions(+), 290 deletions(-)

diff --git a/opendj-dsml-servlet/resources/webapp/web.xml b/opendj-dsml-servlet/resources/webapp/web.xml
index 7d24136..b676c05 100644
--- a/opendj-dsml-servlet/resources/webapp/web.xml
+++ b/opendj-dsml-servlet/resources/webapp/web.xml
@@ -105,6 +105,27 @@
   </context-param>
 -->
 
+  <context-param>
+    <description>Maximum number of batchRequest elements accepted per SOAP
+    body. Every batchRequest element is executed over its own LDAP connection
+    and bind, and password verification is deliberately expensive, so a single
+    small POST holding many batchRequest elements would amplify into many
+    binds. DSMLv2 describes a single batchRequest per SOAP body; raise this cap
+    only if your clients really send more. Excess elements are rejected with a
+    notAttempted errorResponse.</description>
+    <param-name>ldap.dsml.batchrequests.max</param-name>
+    <param-value>1</param-value>
+  </context-param>
+
+  <context-param>
+    <description>Maximum size in bytes of an accepted request body. The SOAP
+    message is parsed into memory, so an unbounded body is an unbounded
+    allocation. Oversized requests are rejected with a notAttempted
+    errorResponse.</description>
+    <param-name>ldap.dsml.request.maxsize</param-name>
+    <param-value>10485760</param-value>
+  </context-param>
+
 <!-- Add an extra <context-param> like the one below for each extended operation
      that is known to return a string in the LDAP response. -->
   <context-param>
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 8163dba..fed9e8f 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
@@ -27,6 +27,7 @@
 import static org.opends.messages.CoreMessages.INFO_RESULT_AUTHORIZATION_DENIED;
 
 import java.io.BufferedInputStream;
+import java.io.FilterInputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
@@ -127,6 +128,16 @@
   private static final String DEREF_ANYURI = "ldap.dsml.dereference.anyuri";
   private static final String DEREF_ANYURI_SCHEMES = "ldap.dsml.dereference.anyuri.schemes";
   private static final String DEREF_ANYURI_MAXSIZE = "ldap.dsml.dereference.anyuri.maxsize";
+  private static final String MAX_BATCH_REQUESTS = "ldap.dsml.batchrequests.max";
+  private static final String REQUEST_MAXSIZE = "ldap.dsml.request.maxsize";
+
+  /**
+   * A SOAP body carries a single batchRequest element by default, as DSMLv2
+   * describes: every extra element costs its own connection and bind.
+   */
+  private static final long DEFAULT_MAX_BATCH_REQUESTS = 1;
+  /** Default cap on the size of an accepted request body, in bytes. */
+  private static final long DEFAULT_REQUEST_MAXSIZE = 10 * 1024 * 1024;
   private static final long serialVersionUID = -3748022009593442973L;
   private static final AtomicInteger nextMessageID = new AtomicInteger(1);
 
@@ -157,6 +168,8 @@
   private String trustStorePasswordValue;
   private Boolean trustAll;
   private Boolean useHTTPAuthzID;
+  private long maxBatchRequests;
+  private long requestMaxSize;
   private final Set<String> exopStrings = new HashSet<>();
 
   /**
@@ -222,18 +235,18 @@
         String maxSize = stringValue(config, DEREF_ANYURI_MAXSIZE);
         if (maxSize != null && !maxSize.trim().isEmpty())
         {
-          try
-          {
-            ByteStringUtility.setMaxUriContentLength(Long.parseLong(maxSize.trim()));
-          }
-          catch (IllegalArgumentException e)
-          {
-            throw new ServletException(DEREF_ANYURI_MAXSIZE
-                + " must be a positive number of bytes, but was: " + maxSize);
-          }
+          ByteStringUtility.setMaxUriContentLength(positiveValue(DEREF_ANYURI_MAXSIZE, maxSize));
         }
       }
 
+      // Every batchRequest element of a SOAP body is executed over its own
+      // connection and bind, and password verification is deliberately
+      // expensive: cap how many binds a single POST may fan out into, and how
+      // much memory its body may claim, so that a small request cannot buy
+      // unbounded work.
+      maxBatchRequests = positiveValue(config, MAX_BATCH_REQUESTS, DEFAULT_MAX_BATCH_REQUESTS);
+      requestMaxSize = positiveValue(config, REQUEST_MAXSIZE, DEFAULT_REQUEST_MAXSIZE);
+
       if(jaxbContext==null)
       {
         jaxbContext = JAXBContext.newInstance(PKG_NAME, getClass().getClassLoader());
@@ -249,8 +262,10 @@
       }
 
       DirectoryServer.bootstrapClient();
+    } catch (ServletException se) {
+      throw se;
     } catch (Exception je) {
-      je.printStackTrace();
+      getServletContext().log("Unable to initialize the DSML gateway", je);
       throw new ServletException(je.getMessage());
     }
   }
@@ -266,6 +281,41 @@
   }
 
   /**
+   * Returns the value of a context-param which must be a positive number, or
+   * the given default when the parameter is absent or empty.
+   */
+  private long positiveValue(ServletConfig config, String paramName, long defaultValue)
+      throws ServletException
+  {
+    String value = stringValue(config, paramName);
+    if (value == null || value.trim().isEmpty())
+    {
+      return defaultValue;
+    }
+    return positiveValue(paramName, value);
+  }
+
+  /** Parses the given context-param value, which must be a positive number. */
+  private long positiveValue(String paramName, String value) throws ServletException
+  {
+    final String message = paramName + " must be a positive number, but was: " + value;
+    final long parsed;
+    try
+    {
+      parsed = Long.parseLong(value.trim());
+    }
+    catch (NumberFormatException e)
+    {
+      throw new ServletException(message);
+    }
+    if (parsed < 1)
+    {
+      throw new ServletException(message);
+    }
+    return parsed;
+  }
+
+  /**
    * Check if using the proxy authz control will work, by using it to read
    * the Root DSE.
    *
@@ -342,313 +392,347 @@
 
     BatchRequest batchRequest = null;
 
+    // The SOAP message is materialised in memory before any of it is
+    // processed, so an unbounded body is an unbounded allocation: refuse to
+    // stream more than the configured cap.
+    final CappedInputStream cappedStream =
+        new CappedInputStream(req.getInputStream(), requestMaxSize);
+
     // Keep the Servlet input stream buffered in case the SOAP un-marshalling
     // fails, the SAX parsing will be able to retrieve the requestID even if
     // the XML is malformed by resetting the input stream.
-    BufferedInputStream is = new BufferedInputStream(req.getInputStream(),
-                                                     65536);
-    if ( is.markSupported() ) {
-      is.mark(65536);
-    }
-
-    // Create response in the beginning as it might be used if the parsing
-    // fails.
-    ObjectFactory objFactory = new ObjectFactory();
-    BatchResponse batchResponse = objFactory.createBatchResponse();
-    List<JAXBElement<?>> batchResponses = batchResponse.getBatchResponses();
-
-    // Thi sis only used for building the response
-    Document doc = createSafeDocument();
-
-    MessageFactory messageFactory = null;
-    String messageContentType = null;
-
-    if (useSSL || useStartTLS)
-    {
-      SSLConnectionFactory sslConnectionFactory = new SSLConnectionFactory();
-      try
-      {
-        sslConnectionFactory.init(trustAll, null, null, null,
-                                  trustStorePathValue, trustStorePasswordValue);
+    try (BufferedInputStream is = new BufferedInputStream(cappedStream, 65536)) {
+      if ( is.markSupported() ) {
+        is.mark(65536);
       }
-      catch(SSLConnectionException e)
+
+      // Create response in the beginning as it might be used if the parsing
+      // fails.
+      ObjectFactory objFactory = new ObjectFactory();
+      BatchResponse batchResponse = objFactory.createBatchResponse();
+      List<JAXBElement<?>> batchResponses = batchResponse.getBatchResponses();
+
+      // Thi sis only used for building the response
+      Document doc = createSafeDocument();
+
+      MessageFactory messageFactory = null;
+      String messageContentType = null;
+
+      if (useSSL || useStartTLS)
       {
-        batchResponses.add(
-          createErrorResponse(objFactory,
-            new LDAPException(LDAPResultCode.CLIENT_SIDE_CONNECT_ERROR,
-              LocalizableMessage.raw(
-              "Invalid SSL or TLS configuration to connect to LDAP server."))));
-      }
-      connOptions.setSSLConnectionFactory(sslConnectionFactory);
-    }
-
-    SOAPBody soapBody = null;
-
-    MimeHeaders mimeHeaders = new MimeHeaders();
-    String bindDN = null;
-    String bindPassword = null;
-    boolean authenticationInHeader = false;
-    boolean authenticationIsID = false;
-    final Enumeration<String> en = req.getHeaderNames();
-    while (en.hasMoreElements()) {
-      String headerName = en.nextElement();
-      String headerVal = req.getHeader(headerName);
-      if (headerName.equalsIgnoreCase("content-type")) {
+        SSLConnectionFactory sslConnectionFactory = new SSLConnectionFactory();
         try
         {
-          if (headerVal.startsWith(SOAPConstants.SOAP_1_1_CONTENT_TYPE))
+          sslConnectionFactory.init(trustAll, null, null, null,
+                                    trustStorePathValue, trustStorePasswordValue);
+        }
+        catch(SSLConnectionException e)
+        {
+          batchResponses.add(
+            createErrorResponse(objFactory,
+              new LDAPException(LDAPResultCode.CLIENT_SIDE_CONNECT_ERROR,
+                LocalizableMessage.raw(
+                "Invalid SSL or TLS configuration to connect to LDAP server."))));
+        }
+        connOptions.setSSLConnectionFactory(sslConnectionFactory);
+      }
+
+      SOAPBody soapBody = null;
+
+      MimeHeaders mimeHeaders = new MimeHeaders();
+      String bindDN = null;
+      String bindPassword = null;
+      boolean authenticationInHeader = false;
+      boolean authenticationIsID = false;
+      final Enumeration<String> en = req.getHeaderNames();
+      while (en.hasMoreElements()) {
+        String headerName = en.nextElement();
+        String headerVal = req.getHeader(headerName);
+        if (headerName.equalsIgnoreCase("content-type")) {
+          try
           {
-            messageFactory = MessageFactory.newInstance(SOAPConstants.SOAP_1_1_PROTOCOL);
-            messageContentType = SOAPConstants.SOAP_1_1_CONTENT_TYPE;
+            if (headerVal.startsWith(SOAPConstants.SOAP_1_1_CONTENT_TYPE))
+            {
+              messageFactory = MessageFactory.newInstance(SOAPConstants.SOAP_1_1_PROTOCOL);
+              messageContentType = SOAPConstants.SOAP_1_1_CONTENT_TYPE;
+            }
+            else if (headerVal.startsWith(SOAPConstants.SOAP_1_2_CONTENT_TYPE))
+            {
+              messageFactory = MessageFactory.newInstance(SOAPConstants.SOAP_1_2_PROTOCOL);
+              messageContentType = SOAPConstants.SOAP_1_2_CONTENT_TYPE;
+            }
+            // An unsupported Content-Type leaves the message factory unset: the
+            // request is rejected as malformed once all the headers are read.
           }
-          else if (headerVal.startsWith(SOAPConstants.SOAP_1_2_CONTENT_TYPE))
+          catch (SOAPException e)
           {
-            messageFactory = MessageFactory.newInstance(SOAPConstants.SOAP_1_2_PROTOCOL);
-            messageContentType = SOAPConstants.SOAP_1_2_CONTENT_TYPE;
+            throw new ServletException(e.getMessage());
           }
-          // An unsupported Content-Type leaves the message factory unset: the
-          // request is rejected as malformed once all the headers are read.
+        } else if (headerName.equalsIgnoreCase("authorization") && headerVal.startsWith("Basic "))
+        {
+          authenticationInHeader = true;
+          String authorization = headerVal.substring(6).trim();
+          try {
+            String unencoded = new String(Base64.decode(authorization).toByteArray());
+            int colon = unencoded.indexOf(':');
+            if (colon > 0) {
+              if (useHTTPAuthzID)
+              {
+                connOptions.setSASLMechanism("mech=" + SASL_MECHANISM_PLAIN);
+                connOptions.addSASLProperty(
+                    "authid=u:" + unencoded.substring(0, colon).trim());
+                authenticationIsID = true;
+              }
+              else
+              {
+                bindDN = unencoded.substring(0, colon).trim();
+              }
+              bindPassword = unencoded.substring(colon + 1);
+            }
+          } catch (final LocalizedIllegalArgumentException ex) {
+            // 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()))));
+            continue;
+          }
+        }
+        StringTokenizer tk = new StringTokenizer(headerVal, ",");
+        while (tk.hasMoreTokens()) {
+          mimeHeaders.addHeader(headerName, tk.nextToken().trim());
+        }
+      }
+
+      if ( ! authenticationInHeader ) {
+        // if no authentication, set default user from web.xml
+        if (userDN != null)
+        {
+          bindDN = userDN;
+          if (userPassword != null)
+          {
+            bindPassword = userPassword;
+          }
+          else
+          {
+            batchResponses.add(
+                createErrorResponse(objFactory,
+                      new LDAPException(LDAPResultCode.INVALID_CREDENTIALS,
+                      LocalizableMessage.raw("Invalid configured credentials."))));
+          }
+        }
+        else
+        {
+          bindDN = "";
+          bindPassword = "";
+        }
+      } else {
+        // otherwise if DN or password is null, send back an error
+        if (((!authenticationIsID && bindDN == null) || bindPassword == null)
+           && batchResponses.isEmpty()) {
+          batchResponses.add(
+                createErrorResponse(objFactory,
+                      new LDAPException(LDAPResultCode.INVALID_CREDENTIALS,
+                      LocalizableMessage.raw("Unable to retrieve credentials."))));
+        }
+      }
+
+      if ( batchResponses.isEmpty() && req.getContentLengthLong() > requestMaxSize ) {
+        // The declared size already exceeds the cap: reject the request before
+        // anything reads the stream — the malformed Content-Type fallback below
+        // SAX-parses the whole body to recover the requestID.
+        batchResponses.add(createErrorResponse(objFactory, requestSizeExceeded()));
+      }
+
+      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());
         }
-      } else if (headerName.equalsIgnoreCase("authorization") && headerVal.startsWith("Basic "))
-      {
-        authenticationInHeader = true;
-        String authorization = headerVal.substring(6).trim();
-        try {
-          String unencoded = new String(Base64.decode(authorization).toByteArray());
-          int colon = unencoded.indexOf(':');
-          if (colon > 0) {
-            if (useHTTPAuthzID)
-            {
-              connOptions.setSASLMechanism("mech=" + SASL_MECHANISM_PLAIN);
-              connOptions.addSASLProperty(
-                  "authid=u:" + unencoded.substring(0, colon).trim());
-              authenticationIsID = true;
-            }
-            else
-            {
-              bindDN = unencoded.substring(0, colon).trim();
-            }
-            bindPassword = unencoded.substring(colon + 1);
-          }
-        } catch (final LocalizedIllegalArgumentException ex) {
-          // 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.
+        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(
-            createErrorResponse(objFactory,
-                  new LDAPException(LDAPResultCode.INVALID_CREDENTIALS,
-                  LocalizableMessage.raw(ex.getMessage()))));
-          continue;
+              createXMLParsingErrorResponse(is,
+                                            objFactory,
+                                            batchResponse,
+                                            "Content-Type does not match SOAP 1.1 or SOAP 1.2"));
         }
       }
-      StringTokenizer tk = new StringTokenizer(headerVal, ",");
-      while (tk.hasMoreTokens()) {
-        mimeHeaders.addHeader(headerName, tk.nextToken().trim());
-      }
-    }
 
-    if ( ! authenticationInHeader ) {
-      // if no authentication, set default user from web.xml
-      if (userDN != null)
-      {
-        bindDN = userDN;
-        if (userPassword != null)
-        {
-          bindPassword = userPassword;
-        }
-        else
-        {
-          batchResponses.add(
-              createErrorResponse(objFactory,
-                    new LDAPException(LDAPResultCode.INVALID_CREDENTIALS,
-                    LocalizableMessage.raw("Invalid configured credentials."))));
-        }
-      }
-      else
-      {
-        bindDN = "";
-        bindPassword = "";
-      }
-    } else {
-      // otherwise if DN or password is null, send back an error
-      if (((!authenticationIsID && bindDN == null) || bindPassword == null)
-         && batchResponses.isEmpty()) {
-        batchResponses.add(
-              createErrorResponse(objFactory,
-                    new LDAPException(LDAPResultCode.INVALID_CREDENTIALS,
-                    LocalizableMessage.raw("Unable to retrieve credentials."))));
-      }
-    }
-
-    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 an error already occurred, the list is not empty
       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 {
-        SOAPMessage message = messageFactory.createMessage(mimeHeaders, is);
-        soapBody = message.getSOAPBody();
-      } catch (SOAPException ex) {
-        // SOAP was unable to parse XML successfully
-        batchResponses.add(
-          createXMLParsingErrorResponse(is,
-                                        objFactory,
-                                        batchResponse,
-                                        String.valueOf(ex.getCause())));
-      }
-    }
-
-    if ( soapBody != null ) {
-      Iterator<?> it = soapBody.getChildElements();
-      while (it.hasNext()) {
-        Object obj = it.next();
-        if (!(obj instanceof SOAPElement)) {
-          continue;
-        }
-        // Parse and unmarshall the SOAP object - the implementation prevents the use of a
-        // DOCTYPE and xincludes, so should be safe. There is no way to configure a more
-        // restrictive parser.
-        SOAPElement se = (SOAPElement) obj;
-        JAXBElement<BatchRequest> batchRequestElement = null;
         try {
-          Unmarshaller unmarshaller = jaxbContext.createUnmarshaller();
-          unmarshaller.setSchema(schema);
-          batchRequestElement = unmarshaller.unmarshal(se, BatchRequest.class);
-        } catch (JAXBException e) {
-          // schema validation failed
-          batchResponses.add(createXMLParsingErrorResponse(is,
-                                                       objFactory,
-                                                       batchResponse,
-                                                       String.valueOf(e)));
-        }
-        if ( batchRequestElement != null ) {
-          boolean authzInBind = false;
-          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)
-           */
-          if (batchRequest.authRequest != null) {
-            if (authenticationIsID) {
-              // If we are using SASL, then use the bind authz.
-              connOptions.addSASLProperty("authzid=" +
-                  batchRequest.authRequest.getPrincipal());
-              authzInBind = true;
-            } else {
-              // If we are using simple then we have to do some work after
-              // the bind.
-              authzInControl = true;
-            }
+          SOAPMessage message = messageFactory.createMessage(mimeHeaders, is);
+          soapBody = message.getSOAPBody();
+        } catch (SOAPException ex) {
+          // SOAP was unable to parse XML successfully
+          batchResponses.add(cappedStream.isLimitExceeded()
+              ? createErrorResponse(objFactory, requestSizeExceeded())
+              : createXMLParsingErrorResponse(is,
+                                              objFactory,
+                                              batchResponse,
+                                              String.valueOf(ex.getCause())));
+        } catch (IOException ex) {
+          if ( ! cappedStream.isLimitExceeded() ) {
+            throw ex;
           }
-          // set requestID in response
-          batchResponse.setRequestID(batchRequest.getRequestID());
-          org.opends.server.types.Control proxyAuthzControl = null;
+          // The body streamed past the cap: chunked, or a lying Content-Length.
+          batchResponses.add(createErrorResponse(objFactory, requestSizeExceeded()));
+        }
+      }
 
-          boolean connected = false;
-
-          // 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);
+      if ( soapBody != null ) {
+        long batchRequestCount = 0;
+        Iterator<?> it = soapBody.getChildElements();
+        while (it.hasNext()) {
+          Object obj = it.next();
+          if (!(obj instanceof SOAPElement)) {
+            continue;
+          }
+          if ( ++batchRequestCount > maxBatchRequests ) {
+            // Each element costs its own connection and bind: refuse to fan a
+            // single POST out into more binds than the configured cap
+            // (MAX_BATCH_REQUESTS), before the element is even schema-validated.
+            // The cap is counted over all the elements of the body, whatever
+            // their type, and its configured value is not echoed to the
+            // unauthenticated client.
+            batchResponses.add(createErrorResponse(objFactory,
+                new LDAPException(LDAPResultCode.UNWILLING_TO_PERFORM,
+                    LocalizableMessage.raw("The SOAP body holds more elements than the configured"
+                        + " maximum: the remaining elements were not attempted."))));
+            break;
+          }
+          // Parse and unmarshall the SOAP object - the implementation prevents the use of a
+          // DOCTYPE and xincludes, so should be safe. There is no way to configure a more
+          // restrictive parser.
+          SOAPElement se = (SOAPElement) obj;
+          JAXBElement<BatchRequest> batchRequestElement = null;
           try {
-            try {
-              connection.connectToHost(bindDN, bindPassword);
-              if (authzInControl)
-              {
-                proxyAuthzControl = checkAuthzControl(connection,
-                    batchRequest.authRequest.getPrincipal());
-              }
-              if (authzInBind || authzInControl)
-              {
-                LDAPResult authResponse = objFactory.createLDAPResult();
-                ResultCode code = ResultCodeFactory.create(objFactory,
-                    LDAPResultCode.SUCCESS);
-                authResponse.setResultCode(code);
-                batchResponses.add(
-                    objFactory.createBatchResponseAuthResponse(authResponse));
-              }
-              connected = true;
-            } catch (LDAPConnectionException e) {
-              // if connection failed, return appropriate error response
-              batchResponses.add(createErrorResponse(objFactory, e));
-            }
-            if ( connected ) {
-              List<DsmlMessage> list = batchRequest.getBatchRequests();
+            Unmarshaller unmarshaller = jaxbContext.createUnmarshaller();
+            unmarshaller.setSchema(schema);
+            batchRequestElement = unmarshaller.unmarshal(se, BatchRequest.class);
+          } catch (JAXBException e) {
+            // schema validation failed
+            batchResponses.add(createXMLParsingErrorResponse(is,
+                                                         objFactory,
+                                                         batchResponse,
+                                                         String.valueOf(e)));
+          }
+          if ( batchRequestElement != null ) {
+            boolean authzInBind = false;
+            boolean authzInControl = false;
+            batchRequest = batchRequestElement.getValue();
 
-              for (DsmlMessage request : list) {
-                JAXBElement<?> result = performLDAPRequest(connection, objFactory, proxyAuthzControl, request);
-                if ( result == null ) {
-                  // an abandon request does not produce any response element
-                  continue;
+            // 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)
+             */
+            if (batchRequest.authRequest != null) {
+              if (authenticationIsID) {
+                // If we are using SASL, then use the bind authz.
+                connOptions.addSASLProperty("authzid=" +
+                    batchRequest.authRequest.getPrincipal());
+                authzInBind = true;
+              } else {
+                // If we are using simple then we have to do some work after
+                // the bind.
+                authzInControl = true;
+              }
+            }
+            // set requestID in response
+            batchResponse.setRequestID(batchRequest.getRequestID());
+            org.opends.server.types.Control proxyAuthzControl = null;
+
+            boolean connected = false;
+
+            // 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)
+                {
+                  proxyAuthzControl = checkAuthzControl(connection,
+                      batchRequest.authRequest.getPrincipal());
                 }
-                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;
+                if (authzInBind || authzInControl)
+                {
+                  LDAPResult authResponse = objFactory.createLDAPResult();
+                  ResultCode code = ResultCodeFactory.create(objFactory,
+                      LDAPResultCode.SUCCESS);
+                  authResponse.setResultCode(code);
+                  batchResponses.add(
+                      objFactory.createBatchResponseAuthResponse(authResponse));
+                }
+                connected = true;
+              } catch (LDAPConnectionException e) {
+                // 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 ) {
+                    // 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;
+                    }
                   }
                 }
               }
+            } finally {
+              // close connection to LDAP server, whatever happened while
+              // processing the batch
+              connection.close(nextMessageID);
             }
-          } finally {
-            // close connection to LDAP server, whatever happened while
-            // processing the batch
-            connection.close(nextMessageID);
           }
         }
       }
-    }
-    try {
-      Marshaller marshaller = jaxbContext.createMarshaller();
-      marshaller.marshal(objFactory.createBatchResponse(batchResponse), doc);
-      sendResponse(doc, messageFactory, messageContentType, res);
-    } catch (Exception e) {
-      // The client gets an empty response: at least make the cause visible.
-      getServletContext().log("Unable to send the DSML response", e);
+      try {
+        Marshaller marshaller = jaxbContext.createMarshaller();
+        marshaller.marshal(objFactory.createBatchResponse(batchResponse), doc);
+        sendResponse(doc, messageFactory, messageContentType, res);
+      } catch (Exception e) {
+        // The client gets an empty response: at least make the cause visible.
+        getServletContext().log("Unable to send the DSML response", e);
+      }
     }
 
   }
@@ -730,6 +814,19 @@
   }
 
   /**
+   * Returns the exception reporting a request body larger than the configured
+   * cap (REQUEST_MAXSIZE); its result code maps to a 'notAttempted' error
+   * response. The configured value is not echoed to the unauthenticated
+   * client.
+   */
+  private LDAPException requestSizeExceeded()
+  {
+    return new LDAPException(LDAPResultCode.UNWILLING_TO_PERFORM,
+        LocalizableMessage.raw(
+            "The request body is larger than the configured maximum: not attempted."));
+  }
+
+  /**
    * Returns an error response with attributes set according to the exception
    * provided as argument.
    *
@@ -1043,5 +1140,68 @@
       return new InputSource(new StringReader(""));
     }
   }
+
+  /**
+   * An input stream which refuses to serve more than a fixed number of bytes,
+   * failing instead of truncating so that an oversized request is rejected
+   * rather than parsed as a shorter one.
+   */
+  private static final class CappedInputStream extends FilterInputStream
+  {
+    private final long limit;
+    private long consumed;
+    private boolean limitExceeded;
+
+    private CappedInputStream(InputStream in, long limit)
+    {
+      super(in);
+      this.limit = limit;
+    }
+
+    private boolean isLimitExceeded()
+    {
+      return limitExceeded;
+    }
+
+    @Override
+    public int read() throws IOException
+    {
+      int b = super.read();
+      if (b >= 0)
+      {
+        count(1);
+      }
+      return b;
+    }
+
+    @Override
+    public int read(byte[] b, int off, int len) throws IOException
+    {
+      int read = super.read(b, off, len);
+      if (read > 0)
+      {
+        count(read);
+      }
+      return read;
+    }
+
+    @Override
+    public long skip(long n) throws IOException
+    {
+      long skipped = super.skip(n);
+      count(skipped);
+      return skipped;
+    }
+
+    private void count(long read) throws IOException
+    {
+      consumed += read;
+      if (consumed > limit)
+      {
+        limitExceeded = true;
+        throw new IOException("request body larger than " + limit + " bytes");
+      }
+    }
+  }
 }
 
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