From 18fd136fa155befe4e43f294c5629c178d5e6414 Mon Sep 17 00:00:00 2001
From: Valera V Harseko <vharseko@3a-systems.ru>
Date: Wed, 21 Oct 2026 08:52:49 +0000
Subject: [PATCH] GHSA-jr68-5c58-j463 DSMLv2 gateway changes XML data binding of other applications in the same JVM
---
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/SoapEntityExpansionTestCase.java | 222 +++++++++++++++++
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLModifyOperation.java | 3
opendj-dsml-servlet/pom.xml | 35 ++
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/JaxbProviderTestCase.java | 86 ++++++
opendj-dsml-servlet/resources/webapp/web.xml | 8
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLSearchOperation.java | 17
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/ByteStringUtilityTestCase.java | 36 ++
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLCompareOperation.java | 3
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLAddOperation.java | 3
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLXmlParsingTestCase.java | 239 ++++++++++++++++++
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLServlet.java | 55 +++
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/ByteStringUtility.java | 31 ++
12 files changed, 713 insertions(+), 25 deletions(-)
diff --git a/opendj-dsml-servlet/pom.xml b/opendj-dsml-servlet/pom.xml
index 520fcc6..214b13e 100644
--- a/opendj-dsml-servlet/pom.xml
+++ b/opendj-dsml-servlet/pom.xml
@@ -79,17 +79,42 @@
<scope>provided</scope>
</dependency>
+ <!--
+ SAAJ, for parsing and building the SOAP envelope. The servlet uses no
+ JAX-WS API, so it depends on the SAAJ API and its implementation
+ rather than on the com.sun.xml.ws:jaxws-ri aggregator, which would
+ also drag the XJC/JXC tools, the documentation and samples archives
+ and - worse - EclipseLink MOXy into WEB-INF/lib. MOXy registers
+ itself as a Jakarta XML Binding provider, so with it on the classpath
+ the provider serving the DSMLv2 bindings is decided by the order the
+ container's classloader happens to use (GHSA-jr68-5c58-j463).
+ -->
<dependency>
- <groupId>com.sun.xml.ws</groupId>
- <artifactId>jaxws-ri</artifactId>
- <version>4.0.3</version>
- <type>pom</type>
+ <groupId>jakarta.xml.soap</groupId>
+ <artifactId>jakarta.xml.soap-api</artifactId>
+ <version>3.0.2</version>
</dependency>
<dependency>
+ <groupId>com.sun.xml.messaging.saaj</groupId>
+ <artifactId>saaj-impl</artifactId>
+ <version>3.0.4</version>
+ <scope>runtime</scope>
+ </dependency>
+
+ <!--
+ Exactly one Jakarta XML Binding provider must end up in WEB-INF/lib:
+ the provider is resolved through the ServiceLoader, so a second one
+ would hand the choice back to the container's classloader ordering.
+ JaxbProviderTestCase fails the build if a dependency reintroduces one.
+ The API version is kept aligned with the runtime: 3.0.x pulls a second
+ jakarta.activation implementation, and its jaxb.properties lookup - a
+ per-package way of pinning the provider - no longer exists in 4.0.x.
+ -->
+ <dependency>
<groupId>jakarta.xml.bind</groupId>
<artifactId>jakarta.xml.bind-api</artifactId>
- <version>3.0.1</version>
+ <version>4.0.2</version>
</dependency>
<dependency>
diff --git a/opendj-dsml-servlet/resources/webapp/web.xml b/opendj-dsml-servlet/resources/webapp/web.xml
index 81476b2..18406d9 100644
--- a/opendj-dsml-servlet/resources/webapp/web.xml
+++ b/opendj-dsml-servlet/resources/webapp/web.xml
@@ -88,6 +88,14 @@
independently, so a DNS name whose records change between the two lookups
(DNS rebinding) can still reach an internal address; only enable
dereferencing for trusted clients.
+ Enabling this also sets the JVM-global "mapAnyUriToUri" system property,
+ which the JAXB reference implementation reads once, when it initialises:
+ every other application in the same JVM that initialises JAXB afterwards
+ then binds xsd:anyURI to java.net.URI instead of to java.lang.String
+ (GHSA-jr68-5c58-j463). In this gateway that binding also makes an anyURI
+ value which is not a valid java.net.URI - one holding a space, a pipe or a
+ brace - be rejected with an unresolvableURI error instead of being stored
+ verbatim.
<context-param>
<description>Enable server-side dereferencing of anyURI values (default false)</description>
<param-name>ldap.dsml.dereference.anyuri</param-name>
diff --git a/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/ByteStringUtility.java b/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/ByteStringUtility.java
index 18cf2c7..d58ad13 100644
--- a/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/ByteStringUtility.java
+++ b/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/ByteStringUtility.java
@@ -184,6 +184,37 @@
}
/**
+ * Returns a ByteString from a DsmlValue Object which the request requires to
+ * carry a value.
+ * <p>
+ * A value the binding could not produce arrives here as {@code null} rather
+ * than as a parse failure: when {@code ldap.dsml.dereference.anyuri} is
+ * enabled, {@code xsd:anyURI} is bound to {@link URI}, and the JAXB runtime
+ * turns a lexical form {@link URI} rejects - one holding a space, a pipe or a
+ * brace, all of them valid {@code xsd:anyURI} - into a {@code null} element.
+ * Passing that on would silently drop the value from an add or modify
+ * request, or build a search filter with no assertion value, so it is
+ * reported to the client instead (GHSA-jr68-5c58-j463).
+ *
+ * @param obj
+ * the DsmlValue object.
+ * @return a new ByteString object with the value, never {@code null}.
+ * @throws IOException if the value is missing or could not be converted, or
+ * if any problem occurred retrieving an anyURI value.
+ */
+ static ByteString convertRequiredValue(Object obj) throws IOException
+ {
+ ByteString value = convertValue(obj);
+ if (value == null)
+ {
+ throw new IOException("Unable to convert a DSML value into an attribute "
+ + "value: the value is missing, or it is an anyURI which is not a "
+ + "valid URI and was dropped by the XML binding");
+ }
+ return value;
+ }
+
+ /**
* Converts an xsd:anyURI value into a ByteString.
* <p>
* Unless dereferencing has been explicitly enabled, the URI is treated as an
diff --git a/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLAddOperation.java b/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLAddOperation.java
index 548c31b..f65fdc8 100644
--- a/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLAddOperation.java
+++ b/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLAddOperation.java
@@ -13,6 +13,7 @@
*
* Copyright 2006-2008 Sun Microsystems, Inc.
* Portions Copyright 2012-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
*/
package org.opends.dsml.protocol;
@@ -90,7 +91,7 @@
List vals = attr.getValue();
for(Object val : vals)
{
- values.add(ByteStringUtility.convertValue(val));
+ values.add(ByteStringUtility.convertRequiredValue(val));
}
LDAPAttribute ldapAttribute = new LDAPAttribute(attr.getName(), values);
attributes.add(ldapAttribute);
diff --git a/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLCompareOperation.java b/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLCompareOperation.java
index 94de1dd..33c6169 100644
--- a/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLCompareOperation.java
+++ b/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLCompareOperation.java
@@ -13,6 +13,7 @@
*
* Copyright 2006-2008 Sun Microsystems, Inc.
* Portions Copyright 2012-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
*/
package org.opends.dsml.protocol;
@@ -81,7 +82,7 @@
AttributeValueAssertion attrValAssertion = compareRequest.getAssertion();
String attrName = attrValAssertion.getName();
Object assertion = attrValAssertion.getValue();
- ByteString attrValue = ByteStringUtility.convertValue(assertion);
+ ByteString attrValue = ByteStringUtility.convertRequiredValue(assertion);
ByteString dnStr = ByteString.valueOfUtf8(compareRequest.getDn());
// Create and send the LDAP compare request to the server.
diff --git a/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLModifyOperation.java b/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLModifyOperation.java
index b43d055..d0a3e36 100644
--- a/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLModifyOperation.java
+++ b/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLModifyOperation.java
@@ -13,6 +13,7 @@
*
* Copyright 2006-2008 Sun Microsystems, Inc.
* Portions Copyright 2012-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
*/
package org.opends.dsml.protocol;
@@ -104,7 +105,7 @@
for (Object val : attr.getValue())
{
- values.add(ByteStringUtility.convertValue(val));
+ values.add(ByteStringUtility.convertRequiredValue(val));
}
LDAPAttribute ldapAttr = new LDAPAttribute(attrType, values);
diff --git a/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLSearchOperation.java b/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLSearchOperation.java
index 4df2a4c..7e00e07 100644
--- a/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLSearchOperation.java
+++ b/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLSearchOperation.java
@@ -13,6 +13,7 @@
*
* Copyright 2006-2009 Sun Microsystems, Inc.
* Portions Copyright 2012-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
*/
package org.opends.dsml.protocol;
@@ -111,7 +112,7 @@
throws IOException
{
return LDAPFilter.createApproximateFilter(ava.getName(),
- ByteStringUtility.convertValue(ava.getValue()));
+ ByteStringUtility.convertRequiredValue(ava.getValue()));
}
@@ -130,7 +131,7 @@
throws IOException
{
return LDAPFilter.createEqualityFilter(ava.getName(),
- ByteStringUtility.convertValue(ava.getValue()));
+ ByteStringUtility.convertRequiredValue(ava.getValue()));
}
@@ -149,7 +150,7 @@
throws IOException
{
return LDAPFilter.createExtensibleFilter(mra.getMatchingRule(), mra
- .getName(), ByteStringUtility.convertValue(mra.getValue()),
+ .getName(), ByteStringUtility.convertRequiredValue(mra.getValue()),
mra.isDnAttributes());
}
@@ -171,7 +172,7 @@
throws IOException
{
return LDAPFilter.createGreaterOrEqualFilter(ava.getName(),
- ByteStringUtility.convertValue(ava.getValue()));
+ ByteStringUtility.convertRequiredValue(ava.getValue()));
}
@@ -191,7 +192,7 @@
throws IOException
{
return LDAPFilter.createLessOrEqualFilter(ava.getName(),
- ByteStringUtility.convertValue(ava.getValue()));
+ ByteStringUtility.convertRequiredValue(ava.getValue()));
}
@@ -282,7 +283,7 @@
for (Object o : anyo)
{
- subAnyElements.add(ByteStringUtility.convertValue(o));
+ subAnyElements.add(ByteStringUtility.convertRequiredValue(o));
}
if(sf.getInitial() == null && subAnyElements.isEmpty()
&& sf.getFinal()==null)
@@ -292,10 +293,10 @@
}
return LDAPFilter.createSubstringFilter(sf.getName(),
sf.getInitial() == null ? null : ByteStringUtility
- .convertValue(sf.getInitial()),
+ .convertRequiredValue(sf.getInitial()),
subAnyElements,
sf.getFinal() == null ? null : ByteStringUtility
- .convertValue(sf.getFinal()));
+ .convertRequiredValue(sf.getFinal()));
}
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 15fefeb..e2b6d47 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
@@ -125,6 +125,11 @@
private static final String TRUSTALLCERTS = "ldap.trustall";
private static final String USEHTTPAUTHZID = "ldap.authzidtypeisid";
private static final String EXOPSTRINGPREFIX = "ldap.exop.string.";
+ /**
+ * JAXB RI system property mapping xsd:anyURI to java.net.URI instead of
+ * java.lang.String. See the caveats in {@link #init(ServletConfig)}.
+ */
+ private static final String MAP_ANYURI_TO_URI = "mapAnyUriToUri";
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";
@@ -225,11 +230,6 @@
}
}
- // Materialise anyURI values as java.net.URI so that, when dereferencing
- // is explicitly enabled, they can be fetched. Dereferencing itself is
- // gated and hardened in ByteStringUtility (disabled by default).
- System.setProperty("mapAnyUriToUri", "true");
-
// Server-side dereferencing of anyURI values is disabled by default
// (SSRF / local-file / unbounded-read hardening, GHSA-68r5-9hpg-7qw9).
// Enable it explicitly, and optionally tune the scheme allowlist and the
@@ -238,6 +238,27 @@
ByteStringUtility.setDereferenceUri(derefAnyUri);
if (derefAnyUri)
{
+ // Materialise anyURI values as java.net.URI, otherwise they are bound
+ // to String and are stored verbatim rather than dereferenced. This is
+ // only done when dereferencing is wanted, because the property comes
+ // with four caveats (GHSA-jr68-5c58-j463):
+ // - it is JVM-global rather than per-JAXBContext, so it also changes
+ // the anyURI binding of every other application initialising JAXB in
+ // this JVM afterwards;
+ // - the JAXB RI tests it for presence, not for its value, so setting
+ // it to "false" would enable the mapping just as well;
+ // - it is read once, by the static initialiser of the JAXB runtime, so
+ // it has no effect if JAXB has already been initialised, and only the
+ // JAXB RI honours it at all - EclipseLink MOXy ignores it, which is
+ // why the gateway must ship exactly one Jakarta XML Binding provider
+ // (pinned by JaxbProviderTestCase);
+ // - the URI binding narrows the accepted values: a lexical form which
+ // is a valid xsd:anyURI but not a valid java.net.URI is turned into a
+ // null by the runtime, and is then reported to the client as an
+ // unresolvableURI error rather than being silently dropped (see
+ // ByteStringUtility.convertRequiredValue).
+ System.setProperty(MAP_ANYURI_TO_URI, "true");
+
String schemes = stringValue(config, DEREF_ANYURI_SCHEMES);
if (schemes != null && !schemes.trim().isEmpty())
{
@@ -345,8 +366,19 @@
}
/**
- * Check if using the proxy authz control will work, by using it to read
- * the Root DSE.
+ * Pre-flight probe telling whether the proxy authz control is usable, by
+ * sending it along with a Root DSE read.
+ * <p>
+ * This is a fail-fast diagnostic, not an access decision: the control it
+ * returns is attached to every operation of the batch request, and the
+ * server evaluates it independently on each of them. That evaluation
+ * requires the authenticated identity to hold the {@code proxied-auth}
+ * privilege, the authorization ID to resolve to a usable entry, and an ACI
+ * granting the authenticated identity the {@code proxy} right over that
+ * entry; ordinary per-entry ACI evaluation then applies to the entries the
+ * operation touches. The probe runs the very same checks - the Root DSE is
+ * served by a local backend - so it only turns an authorization failure that
+ * would happen on the first operation into an error reported up front.
*
* @param connection The authenticated LDAP connection used to check.
* @param authorizationID The authorization ID, in the format
@@ -862,7 +894,9 @@
*
* @return a JAXBElement that contains an ErrorResponse
*/
- private JAXBElement<ErrorResponse> createXMLParsingErrorResponse(
+ // Package-private rather than private so that DSMLXmlParsingTestCase can
+ // exercise the fallback parser on the payloads SAAJ rejects.
+ JAXBElement<ErrorResponse> createXMLParsingErrorResponse(
InputStream is,
ObjectFactory objFactory,
BatchResponse batchResponse,
@@ -1186,7 +1220,10 @@
* @throws ParserConfigurationException if we cannot obtain a parser.
* @throws SAXException if we cannot obtain a parser.
*/
- private XMLReader createSafeXMLReader()
+ // Package-private rather than private so that DSMLXmlParsingTestCase can
+ // assert that the hardening features really did take effect: safeSetFeature
+ // logs and continues when a feature is unsupported by the parser in use.
+ XMLReader createSafeXMLReader()
throws ParserConfigurationException, SAXException
{
final SAXParserFactory saxParserFactory = SAXParserFactory.newInstance();
diff --git a/opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/ByteStringUtilityTestCase.java b/opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/ByteStringUtilityTestCase.java
index bfcda39..9a837c2 100644
--- a/opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/ByteStringUtilityTestCase.java
+++ b/opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/ByteStringUtilityTestCase.java
@@ -322,6 +322,42 @@
ByteStringUtility.setMaxUriContentLength(-1);
}
+ @DataProvider
+ public Object[][] convertibleValues()
+ {
+ return new Object[][] {
+ { "a string value", ByteString.valueOfUtf8("a string value") },
+ { new byte[] { 1, 2, 3 }, ByteString.wrap(new byte[] { 1, 2, 3 }) },
+ { URI.create("http://203.0.113.10/secret"),
+ ByteString.valueOfUtf8("http://203.0.113.10/secret") },
+ };
+ }
+
+ @Test(dataProvider = "convertibleValues")
+ public void testRequiredValueIsConverted(Object value, ByteString expected) throws Exception
+ {
+ assertEquals(ByteStringUtility.convertRequiredValue(value), expected);
+ }
+
+ /**
+ * The XML binding turns an anyURI whose lexical form java.net.URI rejects
+ * into a null value. Storing that would silently drop the value from the
+ * request, so it is reported to the client instead.
+ */
+ @Test(expectedExceptions = IOException.class,
+ expectedExceptionsMessageRegExp = ".*value is missing.*")
+ public void testMissingRequiredValueIsRejected() throws Exception
+ {
+ ByteStringUtility.convertRequiredValue(null);
+ }
+
+ @Test(expectedExceptions = IOException.class,
+ expectedExceptionsMessageRegExp = ".*value is missing.*")
+ public void testUnconvertibleRequiredValueIsRejected() throws Exception
+ {
+ ByteStringUtility.convertRequiredValue(Integer.valueOf(42));
+ }
+
@Test
public void testSchemeCheckPrecedesHostCheck() throws Exception
{
diff --git a/opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLXmlParsingTestCase.java b/opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLXmlParsingTestCase.java
new file mode 100644
index 0000000..f7549ed
--- /dev/null
+++ b/opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLXmlParsingTestCase.java
@@ -0,0 +1,239 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package org.opends.dsml.protocol;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
+import java.util.stream.Stream;
+
+import javax.xml.XMLConstants;
+
+import org.forgerock.testng.ForgeRockTestCase;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+import org.xml.sax.Attributes;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+import org.xml.sax.XMLReader;
+import org.xml.sax.helpers.DefaultHandler;
+
+import jakarta.xml.bind.JAXBElement;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * Tests the XML parsing the gateway configures itself, as opposed to the
+ * parser SAAJ builds internally (which {@link SoapEntityExpansionTestCase}
+ * covers).
+ * <p>
+ * {@code DSMLServlet.createSafeXMLReader()} hardens its reader through
+ * {@code safeSetFeature}, which logs and carries on when a feature is not
+ * supported by the parser in use - so a parser change, or a feature name
+ * change, would disarm it without failing anything. That reader parses
+ * untrusted input: when SAAJ refuses a request, the gateway hands the very same
+ * body to it a second time to recover the requestID
+ * ({@code createXMLParsingErrorResponse}).
+ * <p>
+ * The tests only ever reference {@code file:} URIs under a temporary
+ * directory, so they perform no network or DNS I/O.
+ */
+@SuppressWarnings("javadoc")
+@Test(groups = { "precommit", "dsml" })
+public class DSMLXmlParsingTestCase extends ForgeRockTestCase
+{
+ private static final String CANARY = "s3cr3t-canary-value";
+ private static final String DISALLOW_DOCTYPE = "http://apache.org/xml/features/disallow-doctype-decl";
+ private static final String EXTERNAL_GENERAL_ENTITIES = "http://xml.org/sax/features/external-general-entities";
+ private static final String EXTERNAL_PARAMETER_ENTITIES = "http://xml.org/sax/features/external-parameter-entities";
+
+ private Path tempDir;
+ private String canaryUri;
+ private String externalDtdUri;
+
+ @BeforeClass
+ public void createPayloadFiles() throws IOException
+ {
+ tempDir = Files.createTempDirectory("dsml-parser-test");
+ Path canary = Files.write(tempDir.resolve("canary.txt"), CANARY.getBytes(StandardCharsets.UTF_8));
+ canaryUri = canary.toUri().toASCIIString();
+ Path dtd = Files.write(tempDir.resolve("canary.dtd"),
+ ("<!ENTITY xxe SYSTEM \"" + canaryUri + "\">").getBytes(StandardCharsets.UTF_8));
+ externalDtdUri = dtd.toUri().toASCIIString();
+ }
+
+ @AfterClass
+ public void deletePayloadFiles() throws IOException
+ {
+ if (tempDir != null)
+ {
+ try (Stream<Path> paths = Files.walk(tempDir))
+ {
+ paths.sorted(Comparator.reverseOrder()).forEach(path -> path.toFile().delete());
+ }
+ }
+ }
+
+ /**
+ * The features the reader is hardened with must actually be in effect, not
+ * merely have been requested.
+ */
+ @Test
+ public void testSafeXmlReaderIsHardened() throws Exception
+ {
+ XMLReader xmlReader = new DSMLServlet().createSafeXMLReader();
+
+ assertTrue(xmlReader.getFeature(XMLConstants.FEATURE_SECURE_PROCESSING),
+ "secure processing is not in effect");
+ assertTrue(xmlReader.getFeature(DISALLOW_DOCTYPE),
+ "a DOCTYPE declaration would be accepted");
+ assertFalse(xmlReader.getFeature(EXTERNAL_GENERAL_ENTITIES),
+ "external general entities would be resolved");
+ assertFalse(xmlReader.getFeature(EXTERNAL_PARAMETER_ENTITIES),
+ "external parameter entities would be resolved");
+ }
+
+ @DataProvider
+ public Object[][] entityPayloads()
+ {
+ return new Object[][] {
+ { "external general entity",
+ "<?xml version=\"1.0\"?>"
+ + "<!DOCTYPE batchRequest [<!ENTITY xxe SYSTEM \"" + canaryUri + "\">]>"
+ + batchRequest("&xxe;") },
+ { "external DTD subset",
+ "<?xml version=\"1.0\"?>"
+ + "<!DOCTYPE batchRequest SYSTEM \"" + externalDtdUri + "\">"
+ + batchRequest("&xxe;") },
+ };
+ }
+
+ /**
+ * A DOCTYPE is refused outright; should a future parser accept one, the file
+ * contents must still not reach the parsed document.
+ */
+ @Test(dataProvider = "entityPayloads")
+ public void testSafeXmlReaderNeverExpandsAnExternalEntity(String label, String request) throws Exception
+ {
+ XMLReader xmlReader = new DSMLServlet().createSafeXMLReader();
+ TextCollector collector = new TextCollector();
+ xmlReader.setContentHandler(collector);
+ try
+ {
+ xmlReader.parse(new InputSource(asStream(request)));
+ }
+ catch (SAXException e)
+ {
+ // Refused, which is what disallow-doctype-decl is there for.
+ return;
+ }
+ assertFalse(collector.text.toString().contains(CANARY),
+ label + ": the external entity was expanded: " + collector.text);
+ }
+
+ /** A well-formed request must still parse, otherwise the test above is empty. */
+ @Test
+ public void testSafeXmlReaderParsesAPlainRequest() throws Exception
+ {
+ XMLReader xmlReader = new DSMLServlet().createSafeXMLReader();
+ TextCollector collector = new TextCollector();
+ xmlReader.setContentHandler(collector);
+ xmlReader.parse(new InputSource(asStream(batchRequest("a plain attribute value"))));
+
+ assertEquals(collector.requestID, "42");
+ assertTrue(collector.text.toString().contains("a plain attribute value"),
+ "unexpected content: " + collector.text);
+ }
+
+ /**
+ * The recovery path the servlet takes when SAAJ has refused the request: the
+ * body is parsed a second time, and that second parse must go through the
+ * hardened reader rather than reintroduce the entity expansion.
+ */
+ @Test(dataProvider = "entityPayloads")
+ public void testXmlParsingErrorResponseDoesNotExpandEntities(String label, String request) throws Exception
+ {
+ ObjectFactory objFactory = new ObjectFactory();
+ BatchResponse batchResponse = objFactory.createBatchResponse();
+ InputStream is = asStream(request);
+
+ JAXBElement<ErrorResponse> response = new DSMLServlet()
+ .createXMLParsingErrorResponse(is, objFactory, batchResponse, "parser error");
+
+ assertFalse(String.valueOf(response.getValue().getMessage()).contains(CANARY),
+ label + ": the error response carries the file contents");
+ assertFalse(String.valueOf(batchResponse.getRequestID()).contains(CANARY),
+ label + ": the requestID carries the file contents");
+ }
+
+ /** The same path, on a request the parser accepts: the requestID is recovered. */
+ @Test
+ public void testXmlParsingErrorResponseRecoversTheRequestID() throws Exception
+ {
+ ObjectFactory objFactory = new ObjectFactory();
+ BatchResponse batchResponse = objFactory.createBatchResponse();
+
+ JAXBElement<ErrorResponse> response = new DSMLServlet().createXMLParsingErrorResponse(
+ asStream(batchRequest("a plain attribute value")), objFactory, batchResponse, "parser error");
+
+ assertEquals(batchResponse.getRequestID(), "42");
+ assertEquals(response.getValue().getMessage(), "parser error");
+ }
+
+ /** {@code createXMLParsingErrorResponse} rewinds the stream, so it must support it. */
+ private InputStream asStream(String request)
+ {
+ return new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String batchRequest(String value)
+ {
+ return "<batchRequest xmlns=\"urn:oasis:names:tc:DSML:2:0:core\" requestID=\"42\">"
+ + "<addRequest dn=\"cn=probe,dc=example,dc=com\">"
+ + "<attr name=\"description\"><value>" + value + "</value></attr>"
+ + "</addRequest></batchRequest>";
+ }
+
+ /** Collects the character data of a parsed document, and the requestID. */
+ private static final class TextCollector extends DefaultHandler
+ {
+ private final StringBuilder text = new StringBuilder();
+ private String requestID;
+
+ @Override
+ public void startElement(String uri, String localName, String qName, Attributes attributes)
+ {
+ if (requestID == null && "batchRequest".equals(localName))
+ {
+ requestID = attributes.getValue("requestID");
+ }
+ }
+
+ @Override
+ public void characters(char[] ch, int start, int length)
+ {
+ text.append(ch, start, length);
+ }
+ }
+}
diff --git a/opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/JaxbProviderTestCase.java b/opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/JaxbProviderTestCase.java
new file mode 100644
index 0000000..294c778
--- /dev/null
+++ b/opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/JaxbProviderTestCase.java
@@ -0,0 +1,86 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package org.opends.dsml.protocol;
+
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.forgerock.testng.ForgeRockTestCase;
+import org.testng.annotations.Test;
+
+import jakarta.xml.bind.JAXBContext;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * Pins the gateway to a single, known Jakarta XML Binding provider.
+ * <p>
+ * {@code JAXBContext.newInstance(String, ClassLoader)} resolves its provider
+ * through the {@code ServiceLoader}, so with two providers on the classpath the
+ * one serving the DSMLv2 bindings is whichever the container's classloader
+ * happens to reach first (GHSA-jr68-5c58-j463). That decides observable
+ * behaviour: {@code mapAnyUriToUri}, which the gateway sets when anyURI
+ * dereferencing is enabled, is honoured by the JAXB reference implementation
+ * and ignored by EclipseLink MOXy.
+ * <p>
+ * The web application therefore has to ship exactly one provider. There is no
+ * way to state that in the pom, and pinning it per package through a
+ * {@code jaxb.properties} file is not an option either - that lookup was
+ * removed from the API in 4.0 and is silently ignored there - so it is asserted
+ * here: a dependency change that reintroduces a second provider fails this
+ * test rather than changing behaviour in one container only.
+ */
+@SuppressWarnings("javadoc")
+@Test(groups = { "precommit", "dsml" })
+public class JaxbProviderTestCase extends ForgeRockTestCase
+{
+ private static final String PROVIDER_SERVICE = "META-INF/services/jakarta.xml.bind.JAXBContextFactory";
+ private static final String JAXB_RI_PACKAGE = "org.glassfish.jaxb.runtime.";
+
+ @Test
+ public void testExactlyOneProviderIsOnTheClasspath() throws Exception
+ {
+ List<URL> providers =
+ Collections.list(getClass().getClassLoader().getResources(PROVIDER_SERVICE));
+
+ assertEquals(providers.size(), 1, "expected a single Jakarta XML Binding provider, found "
+ + describe(providers));
+ }
+
+ @Test
+ public void testDsmlBindingsAreServedByTheJaxbReferenceImplementation() throws Exception
+ {
+ JAXBContext context =
+ JAXBContext.newInstance("org.opends.dsml.protocol", getClass().getClassLoader());
+
+ assertTrue(context.getClass().getName().startsWith(JAXB_RI_PACKAGE),
+ "the DSMLv2 bindings are served by " + context.getClass().getName()
+ + ", which does not honour the mapAnyUriToUri property");
+ }
+
+ private String describe(List<URL> providers)
+ {
+ List<String> descriptions = new ArrayList<>(providers.size());
+ for (URL provider : providers)
+ {
+ descriptions.add(provider.toString());
+ }
+ return descriptions.toString();
+ }
+}
diff --git a/opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/SoapEntityExpansionTestCase.java b/opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/SoapEntityExpansionTestCase.java
new file mode 100644
index 0000000..a54a289
--- /dev/null
+++ b/opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/SoapEntityExpansionTestCase.java
@@ -0,0 +1,222 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package org.opends.dsml.protocol;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
+import java.util.stream.Stream;
+
+import javax.xml.parsers.DocumentBuilderFactory;
+
+import org.forgerock.testng.ForgeRockTestCase;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+import org.w3c.dom.Document;
+
+import jakarta.xml.soap.MessageFactory;
+import jakarta.xml.soap.MimeHeaders;
+import jakarta.xml.soap.SOAPConstants;
+import jakarta.xml.soap.SOAPException;
+import jakarta.xml.soap.SOAPMessage;
+
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * Pins the entity-expansion behaviour of the SAAJ implementation the DSMLv2
+ * gateway parses requests with.
+ * <p>
+ * {@link DSMLServlet#doPost} hands the request body straight to
+ * {@code MessageFactory.createMessage(MimeHeaders, InputStream)}, which builds
+ * its own parser: the servlet cannot configure it, and the SAAJ API offers no
+ * way to assert that it is safe. The protection therefore comes from the
+ * implementation on the classpath, and swapping that implementation would
+ * remove it silently - hence this test (GHSA-jr68-5c58-j463).
+ * <p>
+ * The tests only ever reference {@code file:} URIs under a temporary
+ * directory, so they perform no network or DNS I/O.
+ */
+@SuppressWarnings("javadoc")
+@Test(groups = { "precommit", "dsml" })
+public class SoapEntityExpansionTestCase extends ForgeRockTestCase
+{
+ private static final String CANARY = "s3cr3t-canary-value";
+ /** Literal text sitting next to the entity reference in every payload. */
+ private static final String MARKER = "attribute-value-marker";
+ private static final String SOAP_11_CONTENT_TYPE = "text/xml; charset=utf-8";
+ private static final String SOAP_12_CONTENT_TYPE = "application/soap+xml; charset=utf-8";
+
+ private Path tempDir;
+ /** File an XXE payload tries to read. */
+ private String canaryUri;
+ /** External DTD subset declaring an entity which reads the canary file. */
+ private String externalDtdUri;
+
+ @BeforeClass
+ public void createPayloadFiles() throws IOException
+ {
+ tempDir = Files.createTempDirectory("dsml-xxe-test");
+ Path canary = Files.write(tempDir.resolve("canary.txt"), CANARY.getBytes(StandardCharsets.UTF_8));
+ canaryUri = canary.toUri().toASCIIString();
+ Path dtd = Files.write(tempDir.resolve("canary.dtd"),
+ ("<!ENTITY xxe SYSTEM \"" + canaryUri + "\">").getBytes(StandardCharsets.UTF_8));
+ externalDtdUri = dtd.toUri().toASCIIString();
+ }
+
+ @AfterClass
+ public void deletePayloadFiles() throws IOException
+ {
+ if (tempDir != null)
+ {
+ try (Stream<Path> paths = Files.walk(tempDir))
+ {
+ paths.sorted(Comparator.reverseOrder()).forEach(path -> path.toFile().delete());
+ }
+ }
+ }
+
+ @DataProvider
+ public Object[][] entityPayloads()
+ {
+ return new Object[][] {
+ { "SOAP 1.1, external general entity", SOAPConstants.SOAP_1_1_PROTOCOL, SOAP_11_CONTENT_TYPE,
+ "<?xml version=\"1.0\"?>"
+ + "<!DOCTYPE soapenv:Envelope [<!ENTITY xxe SYSTEM \"" + canaryUri + "\">]>"
+ + soap11Envelope("&xxe;") },
+ { "SOAP 1.1, external DTD subset", SOAPConstants.SOAP_1_1_PROTOCOL, SOAP_11_CONTENT_TYPE,
+ "<?xml version=\"1.0\"?>"
+ + "<!DOCTYPE soapenv:Envelope SYSTEM \"" + externalDtdUri + "\">"
+ + soap11Envelope("&xxe;") },
+ { "SOAP 1.2, external general entity", SOAPConstants.SOAP_1_2_PROTOCOL, SOAP_12_CONTENT_TYPE,
+ "<?xml version=\"1.0\"?>"
+ + "<!DOCTYPE env:Envelope [<!ENTITY xxe SYSTEM \"" + canaryUri + "\">]>"
+ + soap12Envelope("&xxe;") },
+ { "SOAP 1.2, external DTD subset", SOAPConstants.SOAP_1_2_PROTOCOL, SOAP_12_CONTENT_TYPE,
+ "<?xml version=\"1.0\"?>"
+ + "<!DOCTYPE env:Envelope SYSTEM \"" + externalDtdUri + "\">"
+ + soap12Envelope("&xxe;") },
+ };
+ }
+
+ /**
+ * A request declaring an external entity must either be rejected outright,
+ * or be parsed without the entity ever being resolved. Both outcomes keep
+ * the file contents out of the batch request; anything else is an XXE.
+ */
+ @Test(dataProvider = "entityPayloads")
+ public void testExternalEntityIsNeverExpanded(String label, String protocol, String contentType,
+ String request) throws Exception
+ {
+ String body;
+ try
+ {
+ body = parseBody(protocol, contentType, request);
+ }
+ catch (SOAPException e)
+ {
+ // Rejected before any entity could be resolved.
+ return;
+ }
+ // The implementation parsed the request instead of rejecting it. The rest
+ // of this method is then the assertion that matters, so first make sure it
+ // is looking at a body that really was parsed - an empty one would satisfy
+ // the check below without proving anything.
+ assertTrue(body.contains(MARKER),
+ label + ": the request was accepted but its body is not there: " + body);
+ assertFalse(body.contains(CANARY),
+ label + ": the external entity was expanded into the request body: " + body);
+ }
+
+ /** The rejection above must not come from the gateway refusing plain requests. */
+ @Test
+ public void testRequestWithoutDoctypeIsParsed() throws Exception
+ {
+ String body = parseBody(SOAPConstants.SOAP_1_1_PROTOCOL, SOAP_11_CONTENT_TYPE,
+ "<?xml version=\"1.0\"?>" + soap11Envelope("a plain attribute value"));
+ assertTrue(body.contains("a plain attribute value"), "unexpected body: " + body);
+ }
+
+ /**
+ * The payloads above are only worth anything if they would actually leak the
+ * file through a parser that is not protected - otherwise the whole test case
+ * could keep passing against an implementation that stopped protecting
+ * anything. This is that control: the very same request, through a plain
+ * DocumentBuilder, must hand the file contents over.
+ */
+ @Test
+ public void testPayloadLeaksThroughAnUnprotectedParser() throws Exception
+ {
+ String request = null;
+ for (Object[] payload : entityPayloads())
+ {
+ if (((String) payload[0]).contains("external general entity"))
+ {
+ request = (String) payload[3];
+ break;
+ }
+ }
+ DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+ factory.setNamespaceAware(true);
+ Document document = factory.newDocumentBuilder()
+ .parse(new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8)));
+ assertTrue(document.getDocumentElement().getTextContent().contains(CANARY),
+ "the XXE payload no longer reads the file even without any protection, "
+ + "so this test case proves nothing anymore");
+ }
+
+ /** Parses a request the way {@link DSMLServlet#doPost} does, and returns the body as text. */
+ private String parseBody(String protocol, String contentType, String request) throws Exception
+ {
+ MimeHeaders mimeHeaders = new MimeHeaders();
+ mimeHeaders.addHeader("Content-Type", contentType);
+ SOAPMessage message = MessageFactory.newInstance(protocol).createMessage(mimeHeaders,
+ new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8)));
+ // SAAJ parses lazily, the body is only built here.
+ return message.getSOAPBody().getTextContent();
+ }
+
+ private static String soap11Envelope(String dn)
+ {
+ return "<soapenv:Envelope xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\">"
+ + "<soapenv:Body>" + batchRequest(dn) + "</soapenv:Body></soapenv:Envelope>";
+ }
+
+ private static String soap12Envelope(String dn)
+ {
+ return "<env:Envelope xmlns:env=\"http://www.w3.org/2003/05/soap-envelope\">"
+ + "<env:Body>" + batchRequest(dn) + "</env:Body></env:Envelope>";
+ }
+
+ /**
+ * A batch request carrying the given text as an attribute value, so that an
+ * expanded entity ends up in the text content of the SOAP body. The value is
+ * prefixed with {@link #MARKER}, which tells a body that was parsed without
+ * expanding the entity from a body that was never parsed at all.
+ */
+ private static String batchRequest(String value)
+ {
+ return "<batchRequest xmlns=\"urn:oasis:names:tc:DSML:2:0:core\">"
+ + "<addRequest dn=\"cn=probe,dc=example,dc=com\">"
+ + "<attr name=\"description\"><value>" + MARKER + value + "</value></attr>"
+ + "</addRequest></batchRequest>";
+ }
+}
--
Gitblit v1.10.0