diff --git a/opendj-dsml-servlet/resources/webapp/web.xml b/opendj-dsml-servlet/resources/webapp/web.xml
index 7d24136c03..b676c0546a 100644
--- a/opendj-dsml-servlet/resources/webapp/web.xml
+++ b/opendj-dsml-servlet/resources/webapp/web.xml
@@ -105,6 +105,27 @@
-->
+
+ 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.
+ ldap.dsml.batchrequests.max
+ 1
+
+
+
+ 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.
+ ldap.dsml.request.maxsize
+ 10485760
+
+
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 8163dba18c..fed9e8fb4f 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 @@ public class DSMLServlet extends HttpServlet {
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 @@ public class DSMLServlet extends HttpServlet {
private String trustStorePasswordValue;
private Boolean trustAll;
private Boolean useHTTPAuthzID;
+ private long maxBatchRequests;
+ private long requestMaxSize;
private final Set exopStrings = new HashSet<>();
/**
@@ -222,18 +235,18 @@ public void init(ServletConfig config) throws ServletException {
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 @@ public void init(ServletConfig config) throws ServletException {
}
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());
}
}
@@ -265,6 +280,41 @@ private String stringValue(ServletConfig config, String paramName)
return config.getServletContext().getInitParameter(paramName);
}
+ /**
+ * 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 @@ public void doPost(HttpServletRequest req, HttpServletResponse res)
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);
- }
+ try (BufferedInputStream is = new BufferedInputStream(cappedStream, 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> batchResponses = batchResponse.getBatchResponses();
+ // Create response in the beginning as it might be used if the parsing
+ // fails.
+ ObjectFactory objFactory = new ObjectFactory();
+ BatchResponse batchResponse = objFactory.createBatchResponse();
+ List> batchResponses = batchResponse.getBatchResponses();
- // Thi sis only used for building the response
- Document doc = createSafeDocument();
+ // Thi sis only used for building the response
+ Document doc = createSafeDocument();
- MessageFactory messageFactory = null;
- String messageContentType = null;
+ MessageFactory messageFactory = null;
+ String messageContentType = null;
- if (useSSL || useStartTLS)
- {
- SSLConnectionFactory sslConnectionFactory = new SSLConnectionFactory();
- try
+ if (useSSL || useStartTLS)
{
- 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 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))
- {
- 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.
+ sslConnectionFactory.init(trustAll, null, null, null,
+ trustStorePathValue, trustStorePasswordValue);
}
- catch (SOAPException e)
+ catch(SSLConnectionException e)
{
- throw new ServletException(e.getMessage());
+ batchResponses.add(
+ createErrorResponse(objFactory,
+ new LDAPException(LDAPResultCode.CLIENT_SIDE_CONNECT_ERROR,
+ LocalizableMessage.raw(
+ "Invalid SSL or TLS configuration to connect to LDAP server."))));
}
- } 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.setSSLConnectionFactory(sslConnectionFactory);
+ }
+
+ SOAPBody soapBody = null;
+
+ MimeHeaders mimeHeaders = new MimeHeaders();
+ String bindDN = null;
+ String bindPassword = null;
+ boolean authenticationInHeader = false;
+ boolean authenticationIsID = false;
+ final Enumeration en = req.getHeaderNames();
+ while (en.hasMoreElements()) {
+ String headerName = en.nextElement();
+ String headerVal = req.getHeader(headerName);
+ if (headerName.equalsIgnoreCase("content-type")) {
+ try
+ {
+ if (headerVal.startsWith(SOAPConstants.SOAP_1_1_CONTENT_TYPE))
{
- connOptions.setSASLMechanism("mech=" + SASL_MECHANISM_PLAIN);
- connOptions.addSASLProperty(
- "authid=u:" + unencoded.substring(0, colon).trim());
- authenticationIsID = true;
+ messageFactory = MessageFactory.newInstance(SOAPConstants.SOAP_1_1_PROTOCOL);
+ messageContentType = SOAPConstants.SOAP_1_1_CONTENT_TYPE;
}
- else
+ else if (headerVal.startsWith(SOAPConstants.SOAP_1_2_CONTENT_TYPE))
{
- bindDN = unencoded.substring(0, colon).trim();
+ messageFactory = MessageFactory.newInstance(SOAPConstants.SOAP_1_2_PROTOCOL);
+ messageContentType = SOAPConstants.SOAP_1_2_CONTENT_TYPE;
}
- bindPassword = unencoded.substring(colon + 1);
+ // An unsupported Content-Type leaves the message factory unset: the
+ // request is rejected as malformed once all the headers are read.
+ }
+ 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.
+ batchResponses.add(
+ createErrorResponse(objFactory,
+ new LDAPException(LDAPResultCode.INVALID_CREDENTIALS,
+ LocalizableMessage.raw(ex.getMessage()))));
+ continue;
}
- } 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());
}
}
- 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)
+ if ( ! authenticationInHeader ) {
+ // if no authentication, set default user from web.xml
+ if (userDN != null)
{
- bindPassword = userPassword;
+ 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("Invalid configured credentials."))));
+ createErrorResponse(objFactory,
+ new LDAPException(LDAPResultCode.INVALID_CREDENTIALS,
+ LocalizableMessage.raw("Unable to retrieve 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 ( 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 ( 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 ( 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 ( 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 ( 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 batchRequestElement = null;
+ // if an error already occurred, the list is not empty
+ if ( batchResponses.isEmpty() ) {
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;
-
- boolean connected = false;
+ // The body streamed past the cap: chunked, or a lying Content-Length.
+ batchResponses.add(createErrorResponse(objFactory, requestSizeExceeded()));
+ }
+ }
- // 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 batchRequestElement = null;
try {
- try {
- connection.connectToHost(bindDN, bindPassword);
- if (authzInControl)
- {
- proxyAuthzControl = checkAuthzControl(connection,
+ 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;
}
- 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 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;
+ // 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());
+ }
+ if (authzInBind || authzInControl)
+ {
+ LDAPResult authResponse = objFactory.createLDAPResult();
+ ResultCode code = ResultCodeFactory.create(objFactory,
+ LDAPResultCode.SUCCESS);
+ authResponse.setResultCode(code);
+ batchResponses.add(
+ objFactory.createBatchResponseAuthResponse(authResponse));
}
- 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;
+ connected = true;
+ } catch (LDAPConnectionException e) {
+ // if connection failed, return appropriate error response
+ batchResponses.add(createErrorResponse(objFactory, e));
+ }
+ if ( connected ) {
+ List 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);
+ }
}
}
@@ -729,6 +813,19 @@ private JAXBElement createXMLParsingErrorResponse(
return objFactory.createBatchResponseErrorResponse(errorResponse);
}
+ /**
+ * 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 @@ public InputSource resolveEntity(String publicId, String systemId)
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 d31a6e560e..d68b5f05be 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 @@ public class DSMLServletTestCase extends ForgeRockTestCase
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 ""
@@ -104,6 +118,16 @@ private static String abandonBatch(String requestID, String authzPrincipal)
+ "";
}
+ private static String searchBatch(String requestID)
+ {
+ return ""
+ + ""
+ + ""
+ + ""
+ + "";
+ }
+
private static String soap11(String body)
{
return ""
@@ -249,16 +273,20 @@ public void testSoap12RequestIsProcessed() throws Exception
/**
* 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 params = new LinkedHashMap<>();
+ params.put("ldap.dsml.batchrequests.max", "2");
+
Map 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);
@@ -270,6 +298,174 @@ public void testEachBatchRequestGetsItsOwnConnection() throws Exception
}
}
+ /**
+ * 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 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 headers = new LinkedHashMap<>();
+ headers.put("Content-Type", SOAP_1_1_CONTENT_TYPE);
+
+ String response = doPost(server.getPort(), Collections. 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 params = new LinkedHashMap<>();
+ params.put("ldap.dsml.request.maxsize", "64");
+
+ Map 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 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. 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. emptyMap(),
+ new LinkedHashMap(), 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 params = new LinkedHashMap<>();
+ params.put("ldap.dsml.request.maxsize",
+ String.valueOf(ABANDON_BATCH.getBytes(StandardCharsets.UTF_8).length));
+
+ Map 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 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
@@ -322,6 +518,7 @@ private String doAuthzPost(FakeLdapServer server, String body) throws Exception
{
Map params = new LinkedHashMap<>();
params.put("ldap.authzidtypeisid", "true");
+ params.put("ldap.dsml.batchrequests.max", "2");
Map headers = new LinkedHashMap<>();
headers.put("Content-Type", SOAP_1_1_CONTENT_TYPE);
@@ -339,6 +536,17 @@ private String doPost(int ldapPort, Map headers, String body) th
private String doPost(int ldapPort, Map extraParams,
Map 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 extraParams,
+ Map headers, String body, long declaredLength) throws Exception
{
Map params = new LinkedHashMap<>();
params.put("ldap.host", InetAddress.getLoopbackAddress().getHostAddress());
@@ -349,7 +557,9 @@ private String doPost(int ldapPort, Map extraParams,
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 @@ private static List list(byte... opTypes)
/**
* 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 @@ private void serveConnection(Socket socket) throws Exception
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 @@ private static ServletConfig servletConfig(final Map params)
"getServletContext".equals(method.getName()) ? context : defaultValue(method));
}
- private static HttpServletRequest httpRequest(final Map headers, final byte[] body)
+ private static HttpServletRequest httpRequest(final Map headers,
+ final byte[] body, final long declaredLength)
{
final ByteArrayInputStream content = new ByteArrayInputStream(body);
final ServletInputStream in = new ServletInputStream()
@@ -564,6 +781,8 @@ public void setReadListener(ReadListener readListener)
{
case "getInputStream":
return in;
+ case "getContentLengthLong":
+ return declaredLength;
case "getHeaderNames":
return Collections.enumeration(headers.keySet());
case "getHeader":