mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valera V Harseko
22 hours ago 18fd136fa155befe4e43f294c5629c178d5e6414
GHSA-jr68-5c58-j463 DSMLv2 gateway changes XML data binding of other applications in the same JVM
9 files modified
3 files added
738 ■■■■■ changed files
opendj-dsml-servlet/pom.xml 35 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/resources/webapp/web.xml 8 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/ByteStringUtility.java 31 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLAddOperation.java 3 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLCompareOperation.java 3 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLModifyOperation.java 3 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLSearchOperation.java 17 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLServlet.java 55 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/ByteStringUtilityTestCase.java 36 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLXmlParsingTestCase.java 239 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/JaxbProviderTestCase.java 86 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/SoapEntityExpansionTestCase.java 222 ●●●●● patch | view | raw | blame | history
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>
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>
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
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);
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.
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);
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()));
  }
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();
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
  {
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLXmlParsingTestCase.java
New file
@@ -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);
    }
  }
}
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/JaxbProviderTestCase.java
New file
@@ -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();
  }
}
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/SoapEntityExpansionTestCase.java
New file
@@ -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>";
  }
}