mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valery Kharseko
16 hours ago 46d8dd0ebd9eafa8eda0260182b37afd0105929d
[#825] Cap the batchRequest count per SOAP body and the request body size in the DSML gateway (#835)
3 files modified
980 ■■■■ changed files
opendj-dsml-servlet/resources/webapp/web.xml 21 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLServlet.java 724 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLServletTestCase.java 235 ●●●●● patch | view | raw | blame | history
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>
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");
      }
    }
  }
}
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":