[#825] Cap the batchRequest count per SOAP body and the request body size in the DSML gateway - #835
Conversation
maximthomas
left a comment
There was a problem hiding this comment.
The design is right: enforce the count cap before unmarshalling, fail rather than truncate on the size cap, keep defaults in code so a stale web.xml is still protected. Verified locally — mvn -o -pl opendj-dsml-servlet test → 73/73, and on master the if (connection == null) guard means the 2nd+ batchRequest is already silently skipped, so the default of 1 breaks no working client.
One blocking defect. Note this can't merge before #811 anyway.
Content-Length check is unguarded and misplaced (blocking)
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLServlet.java:565. Every other pre-parse error path guards on batchResponses.isEmpty(); this one doesn't, and it runs after the messageFactory == null fallback. Two consequences, both confirmed by instrumenting the test harness.
Bad credentials + oversized declared length yields two errors in one reply:
<batchResponse>
<errorResponse type="authenticationFailed"><message>...Unable to retrieve credentials.</message></errorResponse>
<errorResponse type="notAttempted"><message>...larger than ldap.dsml.request.maxsize=10485760 bytes...</message></errorResponse>
</batchResponse>No Content-Type + oversized declared length: two errors again, and the reply carries requestID="1" — proof that createXMLParsingErrorResponse() SAX-parsed the whole body. That is the work the check exists to avoid, and it contradicts "refused without reading the body at all" in the PR description. Capped at 10 MiB so not exploitable, but the guarantee doesn't hold.
Move the check above the messageFactory == null block — still after the header loop, so the client's SOAP version is preserved — and guard it:
if ( batchResponses.isEmpty() && req.getContentLengthLong() > requestMaxSize ) {
// Reject before anything reads the stream: the malformed Content-Type path
// below SAX-parses the body to recover the requestID.
batchResponses.add(createErrorResponse(objFactory, requestSizeExceeded()));
}Worth a test per case — they slipped through because nothing covers them.
Rejection message doesn't match what is counted (minor)
DSMLServlet.java:601. batchRequestCount counts every SOAPElement child, not only batchRequest elements — correct for the DoS goal, since it runs before unmarshalling. But the message then claims "N batchRequest element(s)" for a body mixing element types. Drop the word:
+ " element(s): the remaining elements were not attempted."Cap value disclosed pre-authentication (minor)
DSMLServlet.java:816. requestSizeExceeded() hands an unauthenticated client the configured ldap.dsml.request.maxsize verbatim. Config disclosure for no benefit the client can act on:
LocalizableMessage.raw("The request body is larger than the configured maximum: not attempted.")Nits
NumberFormatExceptionas control flow:DSMLServlet.java:306throws into its owncatchto reuse the message. Reads as an accident, and breaks if the catch ever narrows — parse, then checkparsed < 1separately.- Duplicated parsing:
positiveValue()does the same job as the inlineDEREF_ANYURI_MAXSIZEblock atDSMLServlet.java:235-247. Good chance to collapse it. printStackTrace()ininit(): the newServletExceptiongets stack-printed and rewrapped bycatch (Exception je). Message survives, but #811 routed everything else togetServletContext().log()and documented it in the class javadoc.- Param naming:
ldap.dsml.batchrequests.maxnext toldap.dsml.request.maxsizeandldap.dsml.dereference.anyuri.maxsize. Cosmetic, but a compat break after release — decide now. - No boundary test: a body of exactly
requestMaxSizeis accepted (checked), but nothing pins it, so an off-by-one incount()would go unnoticed. - Weak excess-cap assertion:
testExcessBatchRequestsAreRejectedByDefaultuses abandon requests, which emit no response element, so it only asserts op types. AsearchRequestin the first element would prove partial results still come back alongside thenotAttempted. - Follow-up issue: this closes bind amplification, not operation amplification. Under the new defaults a single POST still carries ~90k
<compareRequest>elements assertinguserPassword, each triggering the same slow password-scheme comparison, all on one bind. Out of scope here — a per-batch operation cap is a real decision against DSMLv2 batch semantics and wants its own issue.
|
Thanks — all addressed in 102f1ec (the branch is rebased onto master now that #811 is merged, so the PR is down to its own two commits). Blocking — declared-size check: moved above the Count message: reworded to "The SOAP body holds more elements than the configured maximum: the remaining elements were not attempted." While at it I also dropped the configured value from this message — it disclosed Cap disclosure: Nits:
Also closed CodeQL's
|
…nd the request body size in the DSML gateway Every batchRequest element of a SOAP body is executed over its own connection and bind, and password verification is deliberately expensive, so a small POST holding many batchRequest elements amplified into many binds; the SOAP message is also parsed into memory, so an unbounded body was an unbounded allocation. Cap both: ldap.dsml.batchrequests.max (default 1, as DSMLv2 describes a single batchRequest per body) and ldap.dsml.request.maxsize (default 10485760 bytes). Excess elements and oversized bodies are rejected with a notAttempted errorResponse; the declared Content-Length is refused without reading the body, and chunked bodies are capped while streamed.
Hoist the declared Content-Length check above the malformed-Content-Type fallback and guard it on an empty batchResponses: it used to add a second errorResponse next to a credentials error, and let the fallback SAX-parse an oversized body to recover the requestID. Stop echoing the configured caps to the unauthenticated client and drop the word batchRequest from the excess message, which counts every element of the SOAP body. Parse the context-params without NumberFormatException as control flow, reuse positiveValue() for ldap.dsml.dereference.anyuri.maxsize, rethrow ServletException out of init() instead of printStackTrace(), and close the request input stream with try-with-resources (CodeQL java/input-resource-leak). New tests: a credentials error is not doubled by the size check, an oversized body without a Content-Type is rejected unread with a single error and no requestID, a body of exactly the cap is accepted, and the excess-batch test now proves partial search results arrive next to the notAttempted error.
22ffb4e to
102f1ec
Compare
maximthomas
left a comment
There was a problem hiding this comment.
There were no changes since the last approval
Fixes #825.
Was stacked on #811; now that it is merged the branch is rebased onto master and holds only this PR's own two commits: the fix and the review round.
What
Since #811 every
batchRequestelement of a SOAP body is executed over its ownLDAPConnectionand therefore its own bind. Password verification is deliberately expensive (PBKDF2, bcrypt and the other salted schemes are tuned to be slow), and a failed bind costs the same as a successful one, so a single small POST holdingNbatchRequestelements amplified intoNexpensive binds. The SOAP message is also parsed into memory with no size bound.How
Two new
web.xmlcontext-params next to the existingldap.*ones:ldap.dsml.batchrequests.max(default 1) — cap on the number of elements accepted per SOAP body, enforced before the element is even schema-validated. DSMLv2 describes a singlebatchRequestper SOAP body, and before [#809] Fix DSML gateway NPE on abandonRequest and on missing Content-Type #811 the second and later elements never worked anyway (they were silently skipped), so the default breaks no working client. Excess elements are rejected with anotAttemptederrorResponse, and the results of the elements under the cap still reach the client next to the error.ldap.dsml.request.maxsize(default 10485760 bytes) — cap on the size of the request body. A declaredContent-Lengthover the cap is refused without reading the body at all: the check runs before the malformed-Content-Type fallback which SAX-parses the body to recover the requestID, and it never adds a second error next to one already reported. Chunked bodies (and clients lying about their length) are capped while streamed by aCappedInputStreamwhich fails instead of truncating.A non-positive or non-numeric value for either parameter is rejected at servlet initialisation. Neither rejection message echoes the configured cap to the unauthenticated client.
The review round also closed CodeQL's
java/input-resource-leakon the request stream with try-with-resources (a whitespace-heavy diff — readDSMLServlet.javawithgit diff -w), routedinit()errors togetServletContext().log()instead ofprintStackTrace(), and collapsed the duplicated context-param parsing intopositiveValue().Tests
New
DSMLServletTestCasecases against the fake LDAP server: the excessbatchRequestis rejected with exactly one bind under the default cap while the search results of the first element still come back next to the error, an oversized declared body and an oversized chunked body are both rejected without the directory server ever being contacted, an oversized body never doubles an already-reported credentials error nor gets SAX-parsed when the Content-Type is missing, a body of exactly the cap is accepted, and invalid parameter values failinit(). The multi-batch tests from #811 raise the cap explicitly to 2.mvn -pl opendj-dsml-servlet test: 76 test invocations, 0 failures.