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":