| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | * 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" }) |
| | |
| | | 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 + "\">" |
| | |
| | | + "</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\"?>" |
| | |
| | | /** |
| | | * 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); |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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. |
| | |
| | | { |
| | | 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); |
| | |
| | | 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)); |
| | |
| | | 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); |
| | | } |
| | | |
| | |
| | | |
| | | /** |
| | | * 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 |
| | | { |
| | |
| | | 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))); |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | "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() |
| | |
| | | { |
| | | case "getInputStream": |
| | | return in; |
| | | case "getContentLengthLong": |
| | | return declaredLength; |
| | | case "getHeaderNames": |
| | | return Collections.enumeration(headers.keySet()); |
| | | case "getHeader": |