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

Valery Kharseko
4 days ago 924eb4a46d87170837d94291120a0fbcf0646f8c
[#843] Cap the number of operations accepted per batchRequest in the DSML gateway (#844)
3 files modified
123 ■■■■■ changed files
opendj-dsml-servlet/resources/webapp/web.xml 15 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLServlet.java 36 ●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLServletTestCase.java 72 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/resources/webapp/web.xml
@@ -126,6 +126,21 @@
    <param-value>10485760</param-value>
  </context-param>
  <context-param>
    <description>Maximum number of operations accepted in one batchRequest.
    A compare on an attribute stored under a salted password scheme (PBKDF2,
    bcrypt, ...) costs a full password verification, so an unbounded batch
    lets a single POST buy an unbounded amount of CPU. Large batches are a
    designed use of DSMLv2 (bulk provisioning), so the default is generous;
    raise it if your clients really send more. A batchRequest holding more
    operations is rejected as a whole with a notAttempted errorResponse,
    before anything is executed. Note that a single POST may hold up to
    ldap.dsml.batchrequests.max batchRequest elements, each allowed this many
    operations.</description>
    <param-name>ldap.dsml.batchrequest.operations.max</param-name>
    <param-value>10000</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
@@ -130,6 +130,7 @@
  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";
  private static final String MAX_OPERATIONS = "ldap.dsml.batchrequest.operations.max";
  /**
   * A SOAP body carries a single batchRequest element by default, as DSMLv2
@@ -138,6 +139,14 @@
  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;
  /**
   * Default cap on the number of operations accepted in one batchRequest.
   * Large batches are a designed use of DSMLv2 (bulk provisioning), so the
   * default is generous; it exists because a compare on an attribute stored
   * under a salted password scheme costs a full password verification, so an
   * unbounded batch would let a single POST buy an unbounded amount of CPU.
   */
  private static final long DEFAULT_MAX_OPERATIONS = 10000;
  private static final long serialVersionUID = -3748022009593442973L;
  private static final AtomicInteger nextMessageID = new AtomicInteger(1);
@@ -170,6 +179,7 @@
  private Boolean useHTTPAuthzID;
  private long maxBatchRequests;
  private long requestMaxSize;
  private long maxOperations;
  private final Set<String> exopStrings = new HashSet<>();
  /**
@@ -240,12 +250,14 @@
      }
      // 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.
      // connection and bind, password verification is deliberately expensive,
      // and a compare on a password attribute costs one too: cap how many
      // binds a single POST may fan out into, how much memory its body may
      // claim, and how many operations one batchRequest may hold, 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);
      maxOperations = positiveValue(config, MAX_OPERATIONS, DEFAULT_MAX_OPERATIONS);
      if(jaxbContext==null)
      {
@@ -655,6 +667,22 @@
            boolean authzInControl = false;
            batchRequest = batchRequestElement.getValue();
            if ( batchRequest.getBatchRequests().size() > maxOperations ) {
              // A compare on an attribute stored under a salted password scheme
              // costs a full password verification, so the number of operations
              // one batchRequest may hold is capped (MAX_OPERATIONS). The batch
              // is refused as a whole before the gateway even connects: a
              // provisioning batch applied halfway is worse than one not
              // attempted. The configured value is not echoed to the
              // unauthenticated client.
              elementResponse.setRequestID(batchRequest.getRequestID());
              elementResponses.add(createErrorResponse(objFactory,
                  new LDAPException(LDAPResultCode.UNWILLING_TO_PERFORM,
                      LocalizableMessage.raw("The batchRequest holds more operations than the"
                          + " configured maximum: none were attempted."))));
              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
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLServletTestCase.java
@@ -79,7 +79,9 @@
 * body used to be silently skipped, and every batch request of a SOAP body
 * used to be answered inside a single shared batchResponse. 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.
 * costs a bind), on the size of the request body, and on the number of
 * operations per batchRequest (a compare on a password attribute costs a
 * password verification).
 */
@SuppressWarnings("javadoc")
@Test(groups = { "precommit", "dsml" })
@@ -125,6 +127,18 @@
        + "</batchRequest>";
  }
  /** A batch request holding the given number of abandon operations. */
  private static String multiOperationBatch(String requestID, int operationCount)
  {
    StringBuilder batch = new StringBuilder(
        "<batchRequest xmlns=\"urn:oasis:names:tc:DSML:2:0:core\" requestID=\"" + requestID + "\">");
    for (int i = 1; i <= operationCount; i++)
    {
      batch.append("<abandonRequest abandonID=\"").append(i).append("\"/>");
    }
    return batch.append("</batchRequest>").toString();
  }
  private static String searchBatch(String requestID)
  {
    return "<batchRequest xmlns=\"urn:oasis:names:tc:DSML:2:0:core\" requestID=\"" + requestID + "\">"
@@ -458,6 +472,59 @@
    }
  }
  /**
   * A batchRequest holding more operations than the configured cap is rejected
   * as a whole before the gateway even connects: a compare on a password
   * attribute costs a password verification, and a provisioning batch applied
   * halfway is worse than one not attempted. The requestID is kept so that the
   * client can correlate the reply.
   */
  @Test
  public void testBatchHoldingMoreOperationsThanTheCapIsRejectedWhole() throws Exception
  {
    try (FakeLdapServer server = new FakeLdapServer())
    {
      Map<String, String> params = new LinkedHashMap<>();
      params.put("ldap.dsml.batchrequest.operations.max", "2");
      Map<String, String> headers = new LinkedHashMap<>();
      headers.put("Content-Type", SOAP_1_1_CONTENT_TYPE);
      String response = doPost(server.getPort(), params, headers,
          soap11(multiOperationBatch("1", 3)));
      assertTrue(response.contains("notAttempted"), response);
      assertTrue(response.contains("requestID=\"1\""), response);
      assertTrue(server.getReceivedOpTypes().isEmpty(),
          "no connection to the directory server should have been opened");
    }
  }
  /** A batch of exactly the configured maximum is accepted: the cap fails only past the limit. */
  @Test
  public void testBatchOfExactlyTheMaximumOperationsIsAccepted() throws Exception
  {
    try (FakeLdapServer server = new FakeLdapServer())
    {
      Map<String, String> params = new LinkedHashMap<>();
      params.put("ldap.dsml.batchrequest.operations.max", "2");
      Map<String, String> headers = new LinkedHashMap<>();
      headers.put("Content-Type", SOAP_1_1_CONTENT_TYPE);
      String response = doPost(server.getPort(), params, headers,
          soap11(multiOperationBatch("1", 2)));
      assertFalse(response.contains("errorResponse"), response);
      server.awaitDisconnect();
      assertEquals(server.getReceivedOpTypes(),
          list(OP_TYPE_BIND_REQUEST, OP_TYPE_ABANDON_REQUEST, OP_TYPE_ABANDON_REQUEST,
               OP_TYPE_UNBIND_REQUEST),
          "a batch 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
@@ -465,7 +532,8 @@
    for (String[] param : new String[][] {
        { "ldap.dsml.batchrequests.max", "0" },
        { "ldap.dsml.batchrequests.max", "banana" },
        { "ldap.dsml.request.maxsize", "-1" } })
        { "ldap.dsml.request.maxsize", "-1" },
        { "ldap.dsml.batchrequest.operations.max", "0" } })
    {
      Map<String, String> params = new LinkedHashMap<>();
      params.put("ldap.host", InetAddress.getLoopbackAddress().getHostAddress());