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

Valery Kharseko
11 hours ago 46d8dd0ebd9eafa8eda0260182b37afd0105929d
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":