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