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

51 files modified
5 files added
2347 ■■■■■ changed files
opendj-cli/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-config/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-core/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-core/src/main/java/org/forgerock/opendj/io/LDAP.java 55 ●●●● patch | view | raw | blame | history
opendj-core/src/main/resources/com/forgerock/opendj/ldap/core.properties 3 ●●●●● patch | view | raw | blame | history
opendj-core/src/test/java/org/forgerock/opendj/io/LDAPFilterDecodeNestingTest.java 139 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-doc-maven-plugin/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-dsml-servlet/pom.xml 10 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/resources/webapp/web.xml 64 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/ByteStringUtility.java 251 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLServlet.java 36 ●●●●● patch | view | raw | blame | history
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/ByteStringUtilityTestCase.java 251 ●●●●● patch | view | raw | blame | history
opendj-embedded-server-examples/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-embedded/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-grizzly/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-ldap-sdk-examples/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-ldap-toolkit/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-legacy/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-maven-plugin/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-openidm-account-change-notification-handler/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-packages/opendj-deb/opendj-deb-standard/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-packages/opendj-deb/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-packages/opendj-docker/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-packages/opendj-msi/opendj-msi-standard/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-packages/opendj-msi/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-packages/opendj-rpm/opendj-rpm-standard/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-packages/opendj-rpm/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-packages/opendj-svr4/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-packages/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-rest2ldap-servlet/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-rest2ldap/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-server-example-plugin/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-server-legacy/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/Storage.java 256 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/EntryContainer.java 27 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/VLVIndex.java 8 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/controls/VLVRequestControl.java 16 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/extensions/PlainSASLMechanismHandler.java 42 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/extensions/SASLContext.java 56 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/protocols/jmx/RmiConnector.java 52 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/protocols/ldap/LDAPRequestHandler.java 11 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/types/RawFilter.java 50 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/messages/org/opends/messages/protocol.properties 3 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/backends/ChangelogBackendTestCase.java 9 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/EncryptedTestCase.java 3 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java 156 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PluggableBackendImplTestCase.java 4 ●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/controls/VLVOffsetAllocationDoSTest.java 126 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/JmxMBeanArgDeserializationTest.java 239 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/RmiAuthenticatorTest.java 40 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/types/PrivilegeTestCase.java 168 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/types/RawFilterDecodeStackOverflowTestCase.java 212 ●●●●● patch | view | raw | blame | history
opendj-server-msad-plugin/pom.xml 2 ●●● patch | view | raw | blame | history
opendj-server/pom.xml 2 ●●● patch | view | raw | blame | history
pom.xml 4 ●●●● patch | view | raw | blame | history
opendj-cli/pom.xml
@@ -20,7 +20,7 @@
    <parent>
        <artifactId>opendj-parent</artifactId>
        <groupId>org.openidentityplatform.opendj</groupId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <artifactId>opendj-cli</artifactId>
opendj-config/pom.xml
@@ -20,7 +20,7 @@
  <parent>
    <groupId>org.openidentityplatform.opendj</groupId>
    <artifactId>opendj-parent</artifactId>
    <version>5.1.2-SNAPSHOT</version>
    <version>5.2.0-SNAPSHOT</version>
  </parent>
  <artifactId>opendj-config</artifactId>
  <name>OpenDJ Configuration API</name>
opendj-core/pom.xml
@@ -21,7 +21,7 @@
    <parent>
        <artifactId>opendj-parent</artifactId>
        <groupId>org.openidentityplatform.opendj</groupId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <artifactId>opendj-core</artifactId>
opendj-core/src/main/java/org/forgerock/opendj/io/LDAP.java
@@ -12,9 +12,12 @@
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2013-2016 ForgeRock AS.
 * Portions Copyright 2024-2026 3A Systems, LLC
 */
package org.forgerock.opendj.io;
import static com.forgerock.opendj.ldap.CoreMessages.ERR_LDAP_FILTER_DECODE_MAX_NESTING_DEPTH;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
@@ -44,6 +47,15 @@
public final class LDAP {
    // @Checkstyle:ignore AvoidNestedBlocks
    /**
     * The maximum number of nested AND/OR/NOT components allowed in a search
     * filter when decoding it from its ASN.1 representation. This bounds the
     * decoder recursion so that a maliciously over-nested filter is rejected
     * rather than overflowing the JVM stack (GHSA-rv4q-c6mr-wxp7). It matches
     * the limit applied on the filter evaluation path.
     */
    private static final int MAX_NESTED_FILTER_DEPTH = 100;
    /** The OID for the Kerberos V GSSAPI mechanism. */
    public static final String OID_GSSAPI_KERBEROS_V = "1.2.840.113554.1.2.2";
@@ -402,14 +414,39 @@
     *             If an error occurs while reading from {@code reader}.
     */
    public static Filter readFilter(final ASN1Reader reader) throws IOException {
        return readFilter(reader, 0);
    }
    /**
     * Reads the next ASN.1 element from the provided {@code ASN1Reader} as a
     * {@code Filter}, tracking the current nesting depth so that a maliciously
     * over-nested filter is rejected rather than overflowing the JVM stack
     * (GHSA-rv4q-c6mr-wxp7).
     *
     * @param reader
     *            The {@code ASN1Reader} from which the ASN.1 encoded
     *            {@code Filter} should be read.
     * @param depth
     *            The current filter nesting depth (0 for the top-level filter).
     * @return The decoded {@code Filter}.
     * @throws IOException
     *             If an error occurs while reading from {@code reader}, or if
     *             the filter is nested more deeply than
     *             {@link #MAX_NESTED_FILTER_DEPTH}.
     */
    private static Filter readFilter(final ASN1Reader reader, final int depth) throws IOException {
        if (depth >= MAX_NESTED_FILTER_DEPTH) {
            throw DecodeException.fatalError(
                    ERR_LDAP_FILTER_DECODE_MAX_NESTING_DEPTH.get(MAX_NESTED_FILTER_DEPTH));
        }
        final byte type = reader.peekType();
        switch (type) {
        case LDAP.TYPE_FILTER_AND:
            return readAndFilter(reader);
            return readAndFilter(reader, depth);
        case LDAP.TYPE_FILTER_OR:
            return readOrFilter(reader);
            return readOrFilter(reader, depth);
        case LDAP.TYPE_FILTER_NOT:
            return readNotFilter(reader);
            return readNotFilter(reader, depth);
        case LDAP.TYPE_FILTER_EQUALITY:
            return readEqualityMatchFilter(reader);
        case LDAP.TYPE_FILTER_GREATER_OR_EQUAL:
@@ -568,13 +605,13 @@
        writer.writeEndSequence();
    }
    private static Filter readAndFilter(final ASN1Reader reader) throws IOException {
    private static Filter readAndFilter(final ASN1Reader reader, final int depth) throws IOException {
        reader.readStartSequence(LDAP.TYPE_FILTER_AND);
        try {
            if (reader.hasNextElement()) {
                final List<Filter> subFilters = new LinkedList<>();
                do {
                    subFilters.add(readFilter(reader));
                    subFilters.add(readFilter(reader, depth + 1));
                } while (reader.hasNextElement());
                return Filter.and(subFilters);
            } else {
@@ -654,22 +691,22 @@
        }
    }
    private static Filter readNotFilter(final ASN1Reader reader) throws IOException {
    private static Filter readNotFilter(final ASN1Reader reader, final int depth) throws IOException {
        reader.readStartSequence(LDAP.TYPE_FILTER_NOT);
        try {
            return Filter.not(readFilter(reader));
            return Filter.not(readFilter(reader, depth + 1));
        } finally {
            reader.readEndSequence();
        }
    }
    private static Filter readOrFilter(final ASN1Reader reader) throws IOException {
    private static Filter readOrFilter(final ASN1Reader reader, final int depth) throws IOException {
        reader.readStartSequence(LDAP.TYPE_FILTER_OR);
        try {
            if (reader.hasNextElement()) {
                final List<Filter> subFilters = new LinkedList<>();
                do {
                    subFilters.add(readFilter(reader));
                    subFilters.add(readFilter(reader, depth + 1));
                } while (reader.hasNextElement());
                return Filter.or(subFilters);
            } else {
opendj-core/src/main/resources/com/forgerock/opendj/ldap/core.properties
@@ -112,6 +112,9 @@
ERR_ATTR_SYNTAX_DN_MAX_DEPTH=The provided value "%s" could not be parsed as a \
 valid distinguished name because it contains more than %d levels of nested \
 distinguished name (DN-syntax) attribute values
ERR_LDAP_FILTER_DECODE_MAX_NESTING_DEPTH=Cannot decode the provided ASN.1 \
 element as an LDAP search filter because it is nested more than %d levels \
 deep, which exceeds the maximum allowed filter nesting depth
WARN_ATTR_SYNTAX_INTEGER_INITIAL_ZERO=The provided value "%s" could \
 not be parsed as a valid integer because the first digit may not be zero \
 unless it is the only digit
opendj-core/src/test/java/org/forgerock/opendj/io/LDAPFilterDecodeNestingTest.java
New file
@@ -0,0 +1,139 @@
/*
 * 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.forgerock.opendj.io;
import java.util.ArrayList;
import java.util.List;
import org.forgerock.opendj.ldap.ByteStringBuilder;
import org.forgerock.opendj.ldap.DecodeException;
import org.forgerock.opendj.ldap.Filter;
import org.forgerock.opendj.ldap.SdkTestCase;
import org.testng.annotations.Test;
import static org.testng.Assert.*;
/**
 * Regression test for GHSA-rv4q-c6mr-wxp7 (OPENDJ-001), SDK decoder twin.
 *
 * <p>{@link LDAP#readFilter(ASN1Reader)} (AND/OR/NOT) recursed once per nesting
 * level with no depth bound, so a maliciously over-nested search filter could
 * overflow the JVM stack with a {@link StackOverflowError} during decode. The
 * fix threads a depth counter and rejects over-nested filters with a
 * {@link DecodeException} before the stack is exhausted.
 *
 * @see org.opends.server.types.RawFilterDecodeStackOverflowTestCase the matching
 *      test for the legacy server decoder ({@code RawFilter.decode}).
 */
@SuppressWarnings("javadoc")
public class LDAPFilterDecodeNestingTest extends SdkTestCase
{
  private static final int OVERFLOW_DEPTH = 100_000;
  private static final long DECODE_STACK_BYTES = 256L * 1024L;
  @Test
  public void shallowNestedFilterDecodes() throws Exception
  {
    Throwable result = decodeOnBoundedStack(buildNestedAndFilter(50));
    assertNull(result, "A 50-level filter must decode cleanly, but threw: " + result);
  }
  @Test
  public void deeplyNestedFilterIsRejectedWithoutStackOverflow() throws Exception
  {
    Throwable result = decodeOnBoundedStack(buildNestedAndFilter(OVERFLOW_DEPTH));
    assertNotNull(result,
        "Decoding a " + OVERFLOW_DEPTH + "-level filter must be rejected, but it succeeded");
    assertFalse(result instanceof StackOverflowError,
        "VULNERABLE: LDAP.readFilter overflowed the stack instead of rejecting: " + result);
    assertTrue(result instanceof DecodeException,
        "Expected a controlled DecodeException, but got: " + result);
  }
  private Throwable decodeOnBoundedStack(final byte[] payload) throws InterruptedException
  {
    final Throwable[] escaped = new Throwable[1];
    Runnable decode = new Runnable()
    {
      @Override
      public void run()
      {
        try
        {
          LDAP.readFilter(ASN1.getReader(payload));
        }
        catch (Throwable t)
        {
          escaped[0] = t;
        }
      }
    };
    Thread worker = new Thread(null, decode, "ghsa-rv4q-sdk-decode", DECODE_STACK_BYTES);
    worker.start();
    worker.join();
    return escaped[0];
  }
  /** Builds the definite-length BER encoding of an AND filter nested {@code depth} deep. */
  private static byte[] buildNestedAndFilter(final int depth) throws Exception
  {
    ByteStringBuilder leafBuilder = new ByteStringBuilder();
    ASN1Writer writer = ASN1.getWriter(leafBuilder);
    LDAP.writeFilter(writer, Filter.present("objectClass"));
    writer.flush();
    byte[] leaf = leafBuilder.toByteArray();
    List<byte[]> prefixes = new ArrayList<>(depth);
    int contentLen = leaf.length;
    for (int i = 0; i < depth; i++)
    {
      byte[] lengthBytes = berLength(contentLen);
      byte[] prefix = new byte[1 + lengthBytes.length];
      prefix[0] = LDAP.TYPE_FILTER_AND; // 0xA0
      System.arraycopy(lengthBytes, 0, prefix, 1, lengthBytes.length);
      prefixes.add(prefix);
      contentLen += prefix.length;
    }
    byte[] out = new byte[contentLen];
    int pos = 0;
    for (int i = prefixes.size() - 1; i >= 0; i--)
    {
      byte[] prefix = prefixes.get(i);
      System.arraycopy(prefix, 0, out, pos, prefix.length);
      pos += prefix.length;
    }
    System.arraycopy(leaf, 0, out, pos, leaf.length);
    return out;
  }
  private static byte[] berLength(final int len)
  {
    if (len < 0x80)
    {
      return new byte[] { (byte) len };
    }
    int numBytes = len <= 0xFF ? 1 : len <= 0xFFFF ? 2 : len <= 0xFFFFFF ? 3 : 4;
    byte[] out = new byte[1 + numBytes];
    out[0] = (byte) (0x80 | numBytes);
    for (int i = 0; i < numBytes; i++)
    {
      out[numBytes - i] = (byte) (len >>> (8 * i));
    }
    return out;
  }
}
opendj-doc-generated-ref/pom.xml
@@ -21,7 +21,7 @@
    <parent>
        <artifactId>opendj-parent</artifactId>
        <groupId>org.openidentityplatform.opendj</groupId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <artifactId>opendj-doc-generated-ref</artifactId>
opendj-doc-maven-plugin/pom.xml
@@ -21,7 +21,7 @@
    <parent>
        <artifactId>opendj-parent</artifactId>
        <groupId>org.openidentityplatform.opendj</groupId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <artifactId>opendj-doc-maven-plugin</artifactId>
opendj-dsml-servlet/pom.xml
@@ -13,6 +13,7 @@
  information: "Portions Copyright [year] [name of copyright owner]".
  Copyright 2015-2016 ForgeRock AS.
  Portions Copyright 2026 3A Systems, LLC.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
@@ -20,7 +21,7 @@
    <parent>
        <groupId>org.openidentityplatform.opendj</groupId>
        <artifactId>opendj-parent</artifactId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <artifactId>opendj-dsml-servlet</artifactId>
@@ -97,6 +98,13 @@
            <version>4.0.5</version>
            <scope>runtime</scope>
        </dependency>
        <!-- Test dependencies (TestNG itself is inherited from the parent) -->
        <dependency>
            <groupId>org.openidentityplatform.commons</groupId>
            <artifactId>build-tools</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build><finalName>${project.groupId}.${project.artifactId}</finalName>
opendj-dsml-servlet/resources/webapp/web.xml
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="ISO-8859-1"?>
<!--
  Portions Copyright 2026 3A Systems, LLC.
-->
<web-app xmlns="http://java.sun.com/xml/ns/j2ee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd"
         version="2.4">
@@ -72,6 +76,35 @@
  </context-param>
-->
<!-- Server-side dereferencing of xsd:anyURI values is DISABLED by default.
     When disabled, an anyURI value is stored verbatim instead of being fetched
     by the gateway. Fetching attacker-supplied URIs server-side is a
     server-side request forgery, local-file disclosure and unbounded-read DoS
     primitive (GHSA-68r5-9hpg-7qw9); enable it only if you fully trust every
     DSML client. When enabled, only the allowlisted schemes are permitted,
     requests to loopback/link-local/private/reserved addresses are rejected,
     HTTP redirects are refused, and the fetched content is capped. Known
     limitation: the address check and the fetch resolve the host name
     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.
  <context-param>
    <description>Enable server-side dereferencing of anyURI values (default false)</description>
    <param-name>ldap.dsml.dereference.anyuri</param-name>
    <param-value>true</param-value>
  </context-param>
  <context-param>
    <description>Comma-separated allowlist of URI schemes to dereference (default http,https)</description>
    <param-name>ldap.dsml.dereference.anyuri.schemes</param-name>
    <param-value>http,https</param-value>
  </context-param>
  <context-param>
    <description>Maximum number of bytes fetched from an anyURI value (default 10485760)</description>
    <param-name>ldap.dsml.dereference.anyuri.maxsize</param-name>
    <param-value>10485760</param-value>
  </context-param>
-->
<!-- Add an extra <context-param> like the one below for each extended operation
     that is known to return a string in the LDAP response. -->
  <context-param>
@@ -90,6 +123,37 @@
    <url-pattern>/DSMLServlet</url-pattern>
  </servlet-mapping>
  <!-- Require container-managed authentication for the gateway so that it is
       not reachable anonymously (GHSA-68r5-9hpg-7qw9). Map real accounts to the
       "dsml-user" role in your servlet container's security realm; the same
       HTTP Basic credentials are then forwarded as the LDAP bind. To serve the
       gateway without authentication (NOT recommended), remove this
       security-constraint, the login-config and the security-role below.
       Uncomment the user-data-constraint to additionally require TLS. -->
  <security-constraint>
    <web-resource-collection>
      <web-resource-name>DSML Gateway</web-resource-name>
      <url-pattern>/DSMLServlet</url-pattern>
    </web-resource-collection>
    <auth-constraint>
      <role-name>dsml-user</role-name>
    </auth-constraint>
<!--
    <user-data-constraint>
      <transport-guarantee>CONFIDENTIAL</transport-guarantee>
    </user-data-constraint>
-->
  </security-constraint>
  <login-config>
    <auth-method>BASIC</auth-method>
    <realm-name>OpenDJ DSML Gateway</realm-name>
  </login-config>
  <security-role>
    <role-name>dsml-user</role-name>
  </security-role>
</web-app>
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/ByteStringUtility.java
@@ -12,12 +12,22 @@
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2012-2016 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC.
 */
package org.opends.dsml.protocol;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.InetAddress;
import java.net.URI;
import java.net.URLConnection;
import java.net.UnknownHostException;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.ByteStringBuilder;
@@ -29,6 +39,79 @@
 */
class ByteStringUtility
{
  /** Number of bytes read from a dereferenced URI per iteration. */
  private static final int READ_CHUNK = 2048;
  /** Connect timeout (ms) applied when dereferencing an anyURI value. */
  private static final int CONNECT_TIMEOUT_MS = 10000;
  /** Read timeout (ms) applied when dereferencing an anyURI value. */
  private static final int READ_TIMEOUT_MS = 30000;
  /**
   * Whether xsd:anyURI values are dereferenced (fetched) server-side.
   * <p>
   * Disabled by default: fetching an attacker-supplied URI on the server is a
   * server-side request forgery, local-file disclosure and unbounded-read DoS
   * primitive (GHSA-68r5-9hpg-7qw9). When disabled, an anyURI value is stored
   * verbatim (its string form) instead of being dereferenced.
   */
  private static volatile boolean dereferenceUri;
  /** URI schemes that may be dereferenced when {@link #dereferenceUri} is enabled. */
  private static volatile Set<String> allowedUriSchemes =
      Collections.unmodifiableSet(new HashSet<>(Arrays.asList("http", "https")));
  /** Maximum number of bytes read from a dereferenced URI (10 MiB by default). */
  private static volatile long maxUriContentLength = 10L * 1024 * 1024;
  /**
   * Enables or disables server-side dereferencing of xsd:anyURI values.
   *
   * @param enabled {@code true} to fetch anyURI values (subject to the scheme
   *                allowlist, address filter and size cap), {@code false} to
   *                store them verbatim.
   */
  static void setDereferenceUri(boolean enabled)
  {
    dereferenceUri = enabled;
  }
  /**
   * Sets the schemes that may be dereferenced from a comma-separated list.
   *
   * @param schemes comma-separated scheme names (e.g. {@code "http,https"}).
   */
  static void setAllowedUriSchemes(String schemes)
  {
    Set<String> parsed = new HashSet<>();
    for (String scheme : schemes.split(","))
    {
      String trimmed = scheme.trim();
      if (!trimmed.isEmpty())
      {
        parsed.add(trimmed.toLowerCase(Locale.ROOT));
      }
    }
    allowedUriSchemes = Collections.unmodifiableSet(parsed);
  }
  /**
   * Sets the maximum number of bytes read from a dereferenced URI.
   *
   * @param maxLength the maximum size, in bytes; must be positive.
   * @throws IllegalArgumentException if the size is zero or negative.
   */
  static void setMaxUriContentLength(long maxLength)
  {
    if (maxLength <= 0)
    {
      throw new IllegalArgumentException(
          "The maximum anyURI content length must be positive, but was: " + maxLength);
    }
    maxUriContentLength = maxLength;
  }
  /**
   * Returns a ByteString from a DsmlValue Object.
   *
@@ -54,16 +137,7 @@
    }
    else if (obj instanceof URI)
    {
      // read raw content and return as a byte[].
      try (InputStream is = ((URI) obj).toURL().openStream())
      {
        ByteStringBuilder bsb = new ByteStringBuilder();
        while (bsb.appendBytes(is, 2048) != -1)
        {
          // do nothing
        }
        return bsb.toByteString();
      }
      return convertUri((URI) obj);
    }
    else if (obj instanceof Element)
    {
@@ -74,6 +148,163 @@
  }
  /**
   * Converts an xsd:anyURI value into a ByteString.
   * <p>
   * Unless dereferencing has been explicitly enabled, the URI is treated as an
   * opaque string and is not fetched. When enabled, the scheme must be on the
   * allowlist, the target must not resolve to a loopback/link-local/private
   * address (SSRF hardening), HTTP redirects are refused (following one would
   * connect to a host that never went through the address filter) and the
   * amount of data read is capped.
   * <p>
   * Known limitation: the address filter and the subsequent connection resolve
   * the host name independently, so DNS records changing between the two
   * lookups (DNS rebinding) can still direct the fetch at an internal address.
   * The JVM's positive DNS cache ({@code networkaddress.cache.ttl}, 30 seconds
   * by default) narrows this window but does not close it; only enable
   * dereferencing for trusted clients.
   *
   * @param uri the anyURI value.
   * @return the resulting ByteString.
   * @throws IOException if the URI is rejected or an I/O error occurs.
   */
  static ByteString convertUri(URI uri) throws IOException
  {
    if (!dereferenceUri)
    {
      // Secure default: do not fetch remote or local content.
      return ByteString.valueOfUtf8(uri.toString());
    }
    String scheme = uri.getScheme();
    if (scheme == null || !allowedUriSchemes.contains(scheme.toLowerCase(Locale.ROOT)))
    {
      throw new IOException(
          "Refusing to dereference anyURI value with disallowed scheme: " + uri);
    }
    assertHostAllowed(uri);
    URLConnection connection = uri.toURL().openConnection();
    connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
    connection.setReadTimeout(READ_TIMEOUT_MS);
    if (connection instanceof HttpURLConnection)
    {
      HttpURLConnection httpConnection = (HttpURLConnection) connection;
      httpConnection.setInstanceFollowRedirects(false);
      assertNotRedirect(httpConnection.getResponseCode(), uri);
    }
    try (InputStream is = connection.getInputStream())
    {
      return readCapped(is, uri);
    }
  }
  /**
   * Rejects HTTP 3xx responses: the redirect target has not been validated by
   * {@link #assertHostAllowed}, so following it would let an allowlisted public
   * host bounce the gateway onto an internal or metadata address.
   *
   * @param responseCode the HTTP response code.
   * @param uri the anyURI value being dereferenced.
   * @throws IOException if the response is a redirect.
   */
  static void assertNotRedirect(int responseCode, URI uri) throws IOException
  {
    if (responseCode >= 300 && responseCode < 400)
    {
      throw new IOException("Refusing to follow redirect (HTTP " + responseCode
          + ") while dereferencing anyURI value: " + uri);
    }
  }
  /**
   * Reads a stream into a ByteString, enforcing the configured size cap.
   *
   * @param is the stream to read.
   * @param uri the anyURI value being dereferenced, for diagnostics.
   * @return the stream content.
   * @throws IOException on I/O error, or if the content exceeds the cap.
   */
  static ByteString readCapped(InputStream is, URI uri) throws IOException
  {
    ByteStringBuilder bsb = new ByteStringBuilder();
    while (bsb.appendBytes(is, READ_CHUNK) != -1)
    {
      if (bsb.length() > maxUriContentLength)
      {
        throw new IOException("anyURI content exceeds the maximum allowed size of "
            + maxUriContentLength + " bytes: " + uri);
      }
    }
    return bsb.toByteString();
  }
  /**
   * Rejects a URI whose host resolves (in whole or in part) to a non-routable
   * or otherwise internal address, to prevent server-side request forgery
   * against internal services and the cloud metadata endpoint.
   *
   * @param uri the anyURI value being dereferenced.
   * @throws IOException if the host is missing, unresolvable or internal.
   */
  static void assertHostAllowed(URI uri) throws IOException
  {
    String host = uri.getHost();
    if (host == null)
    {
      throw new IOException("Refusing to dereference anyURI value with no host: " + uri);
    }
    InetAddress[] addresses;
    try
    {
      addresses = InetAddress.getAllByName(host);
    }
    catch (UnknownHostException e)
    {
      throw new IOException("Unable to resolve anyURI host: " + host, e);
    }
    for (InetAddress address : addresses)
    {
      if (isInternalAddress(address))
      {
        throw new IOException("Refusing to dereference anyURI value targeting a "
            + "non-routable/internal address: " + host + " -> " + address.getHostAddress());
      }
    }
  }
  /**
   * Returns {@code true} if the address is one the gateway must not connect to
   * on behalf of a request: loopback, wildcard, link-local (covers the
   * 169.254.169.254 metadata endpoint), site-local/private, multicast, an
   * IANA-reserved IPv4 range (including 100.64.0.0/10, home of the Alibaba
   * Cloud metadata endpoint 100.100.100.200), or an IPv6 unique-local address
   * (fc00::/7).
   */
  static boolean isInternalAddress(InetAddress address)
  {
    if (address.isLoopbackAddress() || address.isAnyLocalAddress()
        || address.isLinkLocalAddress() || address.isSiteLocalAddress()
        || address.isMulticastAddress())
    {
      return true;
    }
    byte[] bytes = address.getAddress();
    if (bytes.length == 4)
    {
      int b0 = bytes[0] & 0xff;
      int b1 = bytes[1] & 0xff;
      return b0 == 0                                        // 0.0.0.0/8 "this network"
          || (b0 == 100 && (b1 & 0xc0) == 0x40)             // 100.64.0.0/10 CGNAT
          || (b0 == 192 && b1 == 0 && (bytes[2] & 0xff) == 0) // 192.0.0.0/24 IETF assignments
          || (b0 == 198 && (b1 & 0xfe) == 18)               // 198.18.0.0/15 benchmarking
          || b0 >= 240;                                     // 240.0.0.0/4 reserved + broadcast
    }
    // IPv6 unique-local addresses (fc00::/7) are not covered by isSiteLocalAddress().
    return bytes.length == 16 && (bytes[0] & 0xfe) == 0xfc;
  }
  /**
   * Returns a DsmlValue (Object) from an LDAP ByteString. The conversion is
   * simplistic - try and convert it to UTF-8 and if that fails return a byte[].
   *
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLServlet.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2006-2010 Sun Microsystems, Inc.
 * Portions Copyright 2011-2016 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC.
 */
package org.opends.dsml.protocol;
@@ -119,6 +120,9 @@
  private static final String TRUSTALLCERTS = "ldap.trustall";
  private static final String USEHTTPAUTHZID = "ldap.authzidtypeisid";
  private static final String EXOPSTRINGPREFIX = "ldap.exop.string.";
  private static final String DEREF_ANYURI = "ldap.dsml.dereference.anyuri";
  private static final String DEREF_ANYURI_SCHEMES = "ldap.dsml.dereference.anyuri.schemes";
  private static final String DEREF_ANYURI_MAXSIZE = "ldap.dsml.dereference.anyuri.maxsize";
  private static final long serialVersionUID = -3748022009593442973L;
  private static final AtomicInteger nextMessageID = new AtomicInteger(1);
@@ -190,9 +194,39 @@
        }
      }
      // allow the use of anyURI values in adds and modifies
      // 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
      // maximum fetched size, via the corresponding context-params.
      boolean derefAnyUri = booleanValue(config, DEREF_ANYURI);
      ByteStringUtility.setDereferenceUri(derefAnyUri);
      if (derefAnyUri)
      {
        String schemes = stringValue(config, DEREF_ANYURI_SCHEMES);
        if (schemes != null && !schemes.trim().isEmpty())
        {
          ByteStringUtility.setAllowedUriSchemes(schemes);
        }
        String maxSize = stringValue(config, DEREF_ANYURI_MAXSIZE);
        if (maxSize != null && !maxSize.trim().isEmpty())
        {
          try
          {
            ByteStringUtility.setMaxUriContentLength(Long.parseLong(maxSize.trim()));
          }
          catch (IllegalArgumentException e)
          {
            throw new ServletException(DEREF_ANYURI_MAXSIZE
                + " must be a positive number of bytes, but was: " + maxSize);
          }
        }
      }
      if(jaxbContext==null)
      {
        jaxbContext = JAXBContext.newInstance(PKG_NAME, getClass().getClassLoader());
opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/ByteStringUtilityTestCase.java
New file
@@ -0,0 +1,251 @@
/*
 * 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.net.InetAddress;
import java.net.URI;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.testng.ForgeRockTestCase;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertTrue;
/**
 * Tests the anyURI dereferencing hardening added for GHSA-68r5-9hpg-7qw9:
 * secure-by-default (no fetch), scheme allowlist, internal-address filter,
 * redirect refusal and content size cap. None of these tests performs network
 * or DNS I/O: hosts are IP literals and streams are in-memory.
 */
@SuppressWarnings("javadoc")
@Test(groups = { "precommit", "dsml" })
public class ByteStringUtilityTestCase extends ForgeRockTestCase
{
  @AfterMethod
  public void restoreDefaults()
  {
    ByteStringUtility.setDereferenceUri(false);
    ByteStringUtility.setAllowedUriSchemes("http,https");
    ByteStringUtility.setMaxUriContentLength(10L * 1024 * 1024);
  }
  @Test
  public void testUriIsStoredVerbatimByDefault() throws Exception
  {
    ByteString value = ByteStringUtility.convertValue(new URI("http://203.0.113.10/secret"));
    assertEquals(value, ByteString.valueOfUtf8("http://203.0.113.10/secret"));
  }
  @DataProvider
  public Object[][] disallowedSchemeUris()
  {
    return new Object[][] {
      { "file:///etc/passwd" },
      { "ftp://203.0.113.10/file" },
      { "gopher://203.0.113.10/1" },
      { "jar:http://203.0.113.10/a.jar!/b" },
      { "urn:isbn:0451450523" },        // opaque URI, no scheme match
      { "/relative/path" },             // no scheme at all
    };
  }
  @Test(dataProvider = "disallowedSchemeUris", expectedExceptions = IOException.class,
      expectedExceptionsMessageRegExp = ".*disallowed scheme.*")
  public void testDisallowedSchemesAreRejected(String uri) throws Exception
  {
    ByteStringUtility.setDereferenceUri(true);
    ByteStringUtility.convertUri(new URI(uri));
  }
  @Test(expectedExceptions = IOException.class,
      expectedExceptionsMessageRegExp = ".*disallowed scheme.*")
  public void testSchemeAllowlistIsConfigurable() throws Exception
  {
    ByteStringUtility.setDereferenceUri(true);
    ByteStringUtility.setAllowedUriSchemes(" HTTPS , ");
    // http is no longer on the allowlist, so it is rejected before any I/O.
    ByteStringUtility.convertUri(new URI("http://203.0.113.10/"));
  }
  @DataProvider
  public Object[][] internalUris()
  {
    return new Object[][] {
      { "http://127.0.0.1/" },
      { "http://0.0.0.0/" },
      { "http://10.1.2.3/" },
      { "http://172.16.5.5/" },
      { "http://192.168.1.1/" },
      { "http://169.254.169.254/latest/meta-data/" },
      { "http://100.100.100.200/latest/meta-data/" },
      { "http://198.18.0.1/" },
      { "http://240.0.0.1/" },
      { "http://[::1]/" },
      { "http://[fe80::1]/" },
      { "http://[fc00::1]/" },
      { "http://[fd12:3456::1]/" },
    };
  }
  @Test(dataProvider = "internalUris", expectedExceptions = IOException.class,
      expectedExceptionsMessageRegExp = ".*(internal|no host).*")
  public void testInternalAddressesAreRejected(String uri) throws Exception
  {
    ByteStringUtility.assertHostAllowed(new URI(uri));
  }
  @Test(expectedExceptions = IOException.class,
      expectedExceptionsMessageRegExp = ".*no host.*")
  public void testUriWithoutHostIsRejected() throws Exception
  {
    ByteStringUtility.assertHostAllowed(new URI("http:///path"));
  }
  @DataProvider
  public Object[][] addressClassification()
  {
    return new Object[][] {
      // address, expected isInternalAddress
      { "8.8.8.8", false },
      { "1.1.1.1", false },
      { "203.0.113.10", false },
      { "2001:4860:4860::8888", false },
      { "127.0.0.1", true },
      { "0.1.2.3", true },
      { "169.254.169.254", true },
      { "239.255.255.255", true },      // multicast
      { "255.255.255.255", true },      // broadcast
      // 100.64.0.0/10 boundaries
      { "100.63.255.255", false },
      { "100.64.0.0", true },
      { "100.100.100.200", true },
      { "100.127.255.255", true },
      { "100.128.0.0", false },
      // 192.0.0.0/24 boundaries
      { "192.0.0.1", true },
      { "192.0.1.1", false },
      // 198.18.0.0/15 boundaries
      { "198.17.255.255", false },
      { "198.18.0.0", true },
      { "198.19.255.255", true },
      { "198.20.0.0", false },
      // 240.0.0.0/4 boundary
      { "239.0.0.1", true },            // multicast anyway
      { "240.0.0.0", true },
      // IPv6 unique-local fc00::/7 boundaries
      { "fbff::1", false },
      { "fc00::1", true },
      { "fdff::1", true },
      { "fe00::1", false },
    };
  }
  @Test(dataProvider = "addressClassification")
  public void testIsInternalAddress(String literal, boolean expectedInternal) throws Exception
  {
    // IP literals do not trigger DNS resolution.
    InetAddress address = InetAddress.getByName(literal);
    assertEquals(ByteStringUtility.isInternalAddress(address), expectedInternal,
        "isInternalAddress(" + literal + ")");
  }
  @DataProvider
  public Object[][] redirectCodes()
  {
    return new Object[][] { { 300 }, { 301 }, { 302 }, { 303 }, { 307 }, { 308 } };
  }
  @Test(dataProvider = "redirectCodes", expectedExceptions = IOException.class,
      expectedExceptionsMessageRegExp = ".*redirect.*")
  public void testRedirectsAreRefused(int responseCode) throws Exception
  {
    ByteStringUtility.assertNotRedirect(responseCode, new URI("http://203.0.113.10/"));
  }
  @Test
  public void testNonRedirectCodesAreAccepted() throws Exception
  {
    ByteStringUtility.assertNotRedirect(200, new URI("http://203.0.113.10/"));
    ByteStringUtility.assertNotRedirect(404, new URI("http://203.0.113.10/"));
  }
  @Test
  public void testContentWithinCapIsReadFully() throws Exception
  {
    byte[] content = new byte[8192];
    for (int i = 0; i < content.length; i++)
    {
      content[i] = (byte) i;
    }
    ByteStringUtility.setMaxUriContentLength(content.length);
    ByteString value = ByteStringUtility.readCapped(
        new ByteArrayInputStream(content), new URI("http://203.0.113.10/"));
    assertEquals(value, ByteString.wrap(content));
  }
  @Test(expectedExceptions = IOException.class,
      expectedExceptionsMessageRegExp = ".*maximum allowed size.*")
  public void testContentOverCapIsRejected() throws Exception
  {
    ByteStringUtility.setMaxUriContentLength(1024);
    ByteStringUtility.readCapped(
        new ByteArrayInputStream(new byte[1025]), new URI("http://203.0.113.10/"));
  }
  @Test(expectedExceptions = IllegalArgumentException.class)
  public void testZeroMaxSizeIsRejected()
  {
    ByteStringUtility.setMaxUriContentLength(0);
  }
  @Test(expectedExceptions = IllegalArgumentException.class)
  public void testNegativeMaxSizeIsRejected()
  {
    ByteStringUtility.setMaxUriContentLength(-1);
  }
  @Test
  public void testSchemeCheckPrecedesHostCheck() throws Exception
  {
    ByteStringUtility.setDereferenceUri(true);
    // A disallowed scheme must be rejected even for an internal host,
    // and the internal host must be rejected for an allowed scheme.
    try
    {
      ByteStringUtility.convertUri(new URI("file://127.0.0.1/etc/passwd"));
      assertFalse(true, "expected IOException for disallowed scheme");
    }
    catch (IOException e)
    {
      assertTrue(e.getMessage().contains("disallowed scheme"), e.getMessage());
    }
    try
    {
      ByteStringUtility.convertUri(new URI("http://127.0.0.1/"));
      assertFalse(true, "expected IOException for internal address");
    }
    catch (IOException e)
    {
      assertTrue(e.getMessage().contains("internal"), e.getMessage());
    }
  }
}
opendj-embedded-server-examples/pom.xml
@@ -20,7 +20,7 @@
    <parent>
        <artifactId>opendj-parent</artifactId>
        <groupId>org.openidentityplatform.opendj</groupId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <artifactId>opendj-embedded-server-examples</artifactId>
opendj-embedded/pom.xml
@@ -19,7 +19,7 @@
    <parent>
        <groupId>org.openidentityplatform.opendj</groupId>
        <artifactId>opendj-parent</artifactId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <name>OpenDJ Embedded Server</name>
    <artifactId>opendj-embedded</artifactId>
opendj-grizzly/pom.xml
@@ -20,7 +20,7 @@
    <parent>
        <artifactId>opendj-parent</artifactId>
        <groupId>org.openidentityplatform.opendj</groupId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <artifactId>opendj-grizzly</artifactId>
opendj-ldap-sdk-examples/pom.xml
@@ -21,7 +21,7 @@
    <parent>
        <artifactId>opendj-parent</artifactId>
        <groupId>org.openidentityplatform.opendj</groupId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <artifactId>opendj-ldap-sdk-examples</artifactId>
opendj-ldap-toolkit/pom.xml
@@ -20,7 +20,7 @@
    <parent>
        <artifactId>opendj-parent</artifactId>
        <groupId>org.openidentityplatform.opendj</groupId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <artifactId>opendj-ldap-toolkit</artifactId>
opendj-legacy/pom.xml
@@ -19,7 +19,7 @@
  <parent>
    <groupId>org.openidentityplatform.opendj</groupId>
    <artifactId>opendj-parent</artifactId>
    <version>5.1.2-SNAPSHOT</version>
    <version>5.2.0-SNAPSHOT</version>
  </parent>
  <artifactId>opendj-legacy</artifactId>
  <name>OpenDJ Legacy</name>
opendj-maven-plugin/pom.xml
@@ -20,7 +20,7 @@
  <parent>
    <groupId>org.openidentityplatform.opendj</groupId>
    <artifactId>opendj-parent</artifactId>
    <version>5.1.2-SNAPSHOT</version>
    <version>5.2.0-SNAPSHOT</version>
  </parent>
  <artifactId>opendj-maven-plugin</artifactId>
opendj-openidm-account-change-notification-handler/pom.xml
@@ -20,7 +20,7 @@
  <parent>
    <groupId>org.openidentityplatform.opendj</groupId>
    <artifactId>opendj-parent</artifactId>
    <version>5.1.2-SNAPSHOT</version>
    <version>5.2.0-SNAPSHOT</version>
  </parent>
  <artifactId>opendj-openidm-account-change-notification-handler</artifactId>
  <name>OpenDJ account change notification handler for OpenIDM</name>
opendj-packages/opendj-deb/opendj-deb-standard/pom.xml
@@ -20,7 +20,7 @@
    <parent>
        <groupId>org.openidentityplatform.opendj</groupId>
        <artifactId>opendj-deb</artifactId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <artifactId>opendj-deb-standard</artifactId>
opendj-packages/opendj-deb/pom.xml
@@ -22,7 +22,7 @@
    <parent>
        <groupId>org.openidentityplatform.opendj</groupId>
        <artifactId>opendj-packages</artifactId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <profiles>
opendj-packages/opendj-docker/pom.xml
@@ -22,7 +22,7 @@
    <parent>
        <groupId>org.openidentityplatform.opendj</groupId>
        <artifactId>opendj-packages</artifactId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <profiles>
opendj-packages/opendj-msi/opendj-msi-standard/pom.xml
@@ -21,7 +21,7 @@
    <parent>
        <groupId>org.openidentityplatform.opendj</groupId>
        <artifactId>opendj-msi</artifactId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <artifactId>opendj-msi-standard</artifactId>
opendj-packages/opendj-msi/pom.xml
@@ -20,7 +20,7 @@
    <parent>
        <groupId>org.openidentityplatform.opendj</groupId>
        <artifactId>opendj-packages</artifactId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <artifactId>opendj-msi</artifactId>
opendj-packages/opendj-rpm/opendj-rpm-standard/pom.xml
@@ -20,7 +20,7 @@
    <parent>
        <groupId>org.openidentityplatform.opendj</groupId>
        <artifactId>opendj-rpm</artifactId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <artifactId>opendj-rpm-standard</artifactId>
opendj-packages/opendj-rpm/pom.xml
@@ -21,7 +21,7 @@
    <parent>
        <groupId>org.openidentityplatform.opendj</groupId>
        <artifactId>opendj-packages</artifactId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <packaging>pom</packaging>
opendj-packages/opendj-svr4/pom.xml
@@ -20,7 +20,7 @@
    <parent>
        <groupId>org.openidentityplatform.opendj</groupId>
        <artifactId>opendj-packages</artifactId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <artifactId>opendj-svr4</artifactId>
opendj-packages/pom.xml
@@ -20,7 +20,7 @@
    <parent>
        <groupId>org.openidentityplatform.opendj</groupId>
        <artifactId>opendj-parent</artifactId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <artifactId>opendj-packages</artifactId>
opendj-rest2ldap-servlet/pom.xml
@@ -19,7 +19,7 @@
  <parent>
    <groupId>org.openidentityplatform.opendj</groupId>
    <artifactId>opendj-parent</artifactId>
    <version>5.1.2-SNAPSHOT</version>
    <version>5.2.0-SNAPSHOT</version>
  </parent>
  <artifactId>opendj-rest2ldap-servlet</artifactId>
opendj-rest2ldap/pom.xml
@@ -21,7 +21,7 @@
    <parent>
        <artifactId>opendj-parent</artifactId>
        <groupId>org.openidentityplatform.opendj</groupId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    
    <artifactId>opendj-rest2ldap</artifactId>
opendj-server-example-plugin/pom.xml
@@ -19,7 +19,7 @@
  <parent>
    <groupId>org.openidentityplatform.opendj</groupId>
    <artifactId>opendj-parent</artifactId>
    <version>5.1.2-SNAPSHOT</version>
    <version>5.2.0-SNAPSHOT</version>
  </parent>
  <artifactId>opendj-server-example-plugin</artifactId>
  <name>OpenDJ Server Example Plugin</name>
opendj-server-legacy/pom.xml
@@ -21,7 +21,7 @@
  <parent>
    <groupId>org.openidentityplatform.opendj</groupId>
    <artifactId>opendj-parent</artifactId>
    <version>5.1.2-SNAPSHOT</version>
    <version>5.2.0-SNAPSHOT</version>
  </parent>
  <artifactId>opendj-server-legacy</artifactId>
  <packaging>jar</packaging>
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/Storage.java
@@ -11,7 +11,7 @@
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2024-2025 3A Systems, LLC.
 * Copyright 2024-2026 3A Systems, LLC.
 */
package org.opends.server.backends.jdbc;
@@ -282,7 +282,7 @@
            if (((CachedConnection) con).parent.getClass().getName().contains("oracle")) {
                return "h char(128),k raw(2000),v blob,primary key(h,k)";
            }else if (((CachedConnection) con).parent.getClass().getName().contains("mysql")) {
                return "h char(128),k tinyblob,v longblob,primary key(h(128),k(255))";
                return "h char(128),k varbinary(255),v longblob,primary key(h,k)";
            }else if (((CachedConnection) con).parent.getClass().getName().contains("microsoft")) {
                return "h char(128),k varbinary(max),v image,primary key(h)";
            }
@@ -291,15 +291,50 @@
        @Override
        public void openTree(TreeName treeName, boolean createOnDemand) {
            if (createOnDemand && !isExistsTable(treeName)) {
                try (final PreparedStatement statement=con.prepareStatement("create table "+getTableName(treeName)+" ("+getTableDialect()+")")){
                    execute(statement);
                    con.commit();
                }catch (SQLException e) {
                    throw new StorageRuntimeException(e);
            if (createOnDemand) {
                if (!isExistsTable(treeName)) {
                    try (final PreparedStatement statement=con.prepareStatement("create table "+getTableName(treeName)+" ("+getTableDialect()+")")){
                        execute(statement);
                        con.commit();
                    }catch (SQLException e) {
                        throw new StorageRuntimeException(e);
                    }
                }
                // CursorImpl iterates with "where k>? order by k" batches: primary key (h,k) cannot serve them
                final String driverName=((CachedConnection) con).parent.getClass().getName();
                final String tableName=getTableName(treeName);
                if (driverName.contains("postgres")) {
                    try (final PreparedStatement statement=con.prepareStatement("create index if not exists k_"+tableName.substring("opendj_".length())+" on "+tableName+" (k)")){
                        execute(statement);
                        con.commit();
                    }catch (SQLException e) {
                        throw new StorageRuntimeException(e);
                    }
                }else if (driverName.contains("mysql")) {
                    try {
                        if (!isExistsIndex(tableName,"k_"+tableName.substring("opendj_".length()))) { // mysql has no "create index if not exists"
                            try (final PreparedStatement statement=con.prepareStatement("create index k_"+tableName.substring("opendj_".length())+" on "+tableName+" (k)")){
                                execute(statement);
                                con.commit();
                            }
                        }
                    }catch (SQLException e) {
                        throw new StorageRuntimeException(e);
                    }
                }
            }
        }
        boolean isExistsIndex(String tableName, String indexName) throws SQLException {
            try (final ResultSet rs = con.getMetaData().getIndexInfo(null, null, tableName, false, false)) {
                while (rs.next()) {
                    if (indexName.equalsIgnoreCase(rs.getString("INDEX_NAME"))) {
                        return true;
                    }
                }
            }
            return false;
        }
        
        public void clearTree(TreeName treeName) {
            try (final PreparedStatement statement=con.prepareStatement("delete from "+getTableName(treeName))){
@@ -414,74 +449,102 @@
        }
    }
    
    // Iterates in batches of "fetchsize" records via keyset pagination ("where k>? order by k limit n"):
    // scrollable ResultSet is not an option, the postgres/mysql drivers materialize it entirely in memory.
    private final class CursorImpl implements Cursor<ByteString, ByteString> {
        final TreeName treeName;
        final PreparedStatement statement;
        final ResultSet rc;
        final Connection con;
        final String tableName;
        final boolean isReadOnly;
        final int batchSize=Math.max(1,Integer.getInteger("org.openidentityplatform.opendj.jdbc.fetchsize",1000));
        final String limitClause;
        final ArrayDeque<byte[][]> buffer=new ArrayDeque<>();
        byte[] currentKeyDb;
        ByteString currentKey;
        ByteString currentValue;
        boolean defined;
        public CursorImpl(boolean isReadOnly, Connection con, TreeName treeName) {
            this.treeName=treeName;
            this.isReadOnly=isReadOnly;
            try {
                statement=con.prepareStatement("select h,k,v from "+getTableName(treeName)+" order by k",
                    isReadOnly?ResultSet.TYPE_SCROLL_INSENSITIVE:ResultSet.TYPE_SCROLL_SENSITIVE,
                    isReadOnly?ResultSet.CONCUR_READ_ONLY:ResultSet.CONCUR_UPDATABLE);
                rc=executeResultSet(statement);
            this.con=con;
            this.tableName=getTableName(treeName);
            this.limitClause=((CachedConnection)con).parent.getClass().getName().contains("mysql")
                ? " limit ?,?" : " offset ? rows fetch next ? rows only";
        }
        boolean fetchBatch(String condition, byte[] dbKey, long offset, boolean descending, int limit) {
            buffer.clear();
            try (final PreparedStatement statement=con.prepareStatement("select k,v from "+tableName
                    +(condition!=null?" where k"+condition+"?":"")
                    +" order by k"+(descending?" desc":"")+limitClause)){
                int i=1;
                if (condition!=null) {
                    statement.setBytes(i++,dbKey);
                }
                statement.setLong(i++,offset);
                statement.setLong(i,limit);
                try(final ResultSet rc=executeResultSet(statement)) {
                    while (rc.next()) {
                        buffer.add(new byte[][]{rc.getBytes(1),rc.getBytes(2)});
                    }
                }
            }catch (SQLException e) {
                throw new StorageRuntimeException(e);
            }
            return !buffer.isEmpty();
        }
        void advanceFromBuffer() {
            final byte[][] row=buffer.poll();
            currentKeyDb=row[0];
            currentKey=ByteString.wrap(db2real(row[0]));
            currentValue=ByteString.wrap(row[1]);
            defined=true;
        }
        @Override
        public boolean next() {
            try {
                return rc.next();
            }catch (SQLException e) {
                throw new StorageRuntimeException(e);
            if (buffer.isEmpty() && !fetchBatch(currentKeyDb==null?null:">",currentKeyDb,0,false,batchSize)) {
                defined=false;
                return false;
            }
            advanceFromBuffer();
            return true;
        }
        @Override
        public boolean isDefined() {
            try{
                return rc.getRow()>0;
            }catch (SQLException e) {
                throw new StorageRuntimeException(e);
            }
            return defined;
        }
        @Override
        public ByteString getKey() throws NoSuchElementException {
            if (!isDefined()) {
            if (!defined) {
                throw new NoSuchElementException();
            }
            try{
                return ByteString.wrap(db2real(rc.getBytes("k")));
            }catch (SQLException e) {
                throw new StorageRuntimeException(e);
            }
            return currentKey;
        }
        @Override
        public ByteString getValue() throws NoSuchElementException {
            if (!isDefined()) {
            if (!defined) {
                throw new NoSuchElementException();
            }
            try{
                return ByteString.wrap(rc.getBytes("v"));
            }catch (SQLException e) {
                throw new StorageRuntimeException(e);
            }
            return currentValue;
        }
        @Override
        public void delete() throws NoSuchElementException, UnsupportedOperationException {
            if (!isDefined()) {
            if (!defined) {
                throw new NoSuchElementException();
            }
            try{
                rc.deleteRow();
            if (isReadOnly) {
                throw new UnsupportedOperationException();
            }
            try (final PreparedStatement statement=con.prepareStatement("delete from "+tableName+" where h=? and k=?")){
                statement.setString(1,key2hash.get(ByteBuffer.wrap(db2real(currentKeyDb))));
                statement.setBytes(2,currentKeyDb);
                execute(statement);
            }catch (SQLException e) {
                throw new StorageRuntimeException(e);
            }
@@ -489,97 +552,60 @@
        @Override
        public void close() {
            try{
                rc.close();
                statement.close();
            }catch (SQLException e) {
                throw new StorageRuntimeException(e);
            }
            buffer.clear();
            defined=false;
        }
        @Override
        public boolean positionToKeyOrNext(ByteSequence key) {
            if (!isDefined() || key.compareTo(getKey())<0) { //restart iterator
                try{
                    rc.first();
                }catch (SQLException e) {
                    throw new StorageRuntimeException(e);
                }
            }
            try{
                if (!isDefined()){
                    return false;
                }
                do {
                    if (key.compareTo(getKey())<=0) {
                        return true;
                    }
                }while(rc.next());
            }catch (SQLException e) {
                throw new StorageRuntimeException(e);
            }
            return false;
        }
        @Override
        public boolean positionToKey(ByteSequence key) {
            if (!isDefined() || key.compareTo(getKey())<0) {  //restart iterator
                try{
                    rc.first();
                }catch (SQLException e) {
                    throw new StorageRuntimeException(e);
                }
            }
            if (!isDefined()){
                return false;
            }
            if (isDefined() && key.compareTo(getKey())==0) {
            if (fetchBatch(">=",real2db(key.toByteArray()),0,false,batchSize)) {
                advanceFromBuffer();
                return true;
            }
            try{
                do {
                    if (key.compareTo(getKey())==0) {
                        return true;
                    }
                }while(rc.next());
            }catch (SQLException e) {
                throw new StorageRuntimeException(e);
            }
            defined=false;
            return false;
        }
        @Override
        public boolean positionToLastKey() {
            try{
                return rc.last();
        public boolean positionToKey(ByteSequence key) {
            final byte[] real=key.toByteArray();
            try (final PreparedStatement statement=con.prepareStatement("select v from "+tableName+" where h=? and k=?")){
                statement.setString(1,key2hash.get(ByteBuffer.wrap(real)));
                statement.setBytes(2,real2db(real));
                try(final ResultSet rc=executeResultSet(statement)) {
                    if (rc.next()) {
                        buffer.clear();
                        currentKeyDb=real2db(real);
                        currentKey=ByteString.wrap(real);
                        currentValue=ByteString.wrap(rc.getBytes("v"));
                        defined=true;
                        return true;
                    }
                }
            }catch (SQLException e) {
                throw new StorageRuntimeException(e);
            }
            defined=false;
            return false;
        }
        @Override
        public boolean positionToLastKey() {
            if (fetchBatch(null,null,0,true,1)) {
                advanceFromBuffer();
                return true;
            }
            defined=false;
            return false;
        }
        @Override
        public boolean positionToIndex(int index) {
            try{
                rc.first();
            }catch (SQLException e) {
                throw new StorageRuntimeException(e);
            if (index>=0 && fetchBatch(null,null,index,false,batchSize)) {
                advanceFromBuffer();
                return true;
            }
            if (!isDefined()){
                return false;
            }
            int ct=0;
            try{
                do {
                    if (ct==index) {
                        return true;
                    }
                    ct++;
                }while(rc.next());
            }catch (SQLException e) {
                throw new StorageRuntimeException(e);
            }
            defined=false;
            return false;
        }
    }
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/EntryContainer.java
@@ -14,6 +14,7 @@
 * Copyright 2006-2010 Sun Microsystems, Inc.
 * Portions Copyright 2011-2016 ForgeRock AS.
 * Portions copyright 2013 Manuel Gaupp
 * Portions Copyright 2026 3A Systems, LLC
 */
package org.opends.server.backends.pluggable;
@@ -2688,21 +2689,29 @@
      afterCount = 0;
    }
    int count = 1 + beforeCount + afterCount;
    // Never allocate more longs than the number of entries actually available
    // from startPos, and compute the size with long arithmetic so an
    // attacker-supplied before/after count cannot overflow or drive an oversized
    // array (GHSA-q4wx-wj4j-4657).
    final int available = startPos >= 0 && startPos < sortMap.size() ? sortMap.size() - startPos : 0;
    final int count = (int) Math.max(0L, Math.min(1L + beforeCount + afterCount, available));
    long[] sortedIDs = new long[count];
    int treePos = 0;
    int arrayPos = 0;
    for (EntryID id : sortMap.values())
    if (count > 0)
    {
      if (treePos++ < startPos)
      for (EntryID id : sortMap.values())
      {
        continue;
      }
        if (treePos++ < startPos)
        {
          continue;
        }
      sortedIDs[arrayPos++] = id.longValue();
      if (arrayPos >= count)
      {
        break;
        sortedIDs[arrayPos++] = id.longValue();
        if (arrayPos >= count)
        {
          break;
        }
      }
    }
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/VLVIndex.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2006-2008 Sun Microsystems, Inc.
 * Portions Copyright 2011-2016 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC
 */
package org.opends.server.backends.pluggable;
@@ -689,7 +690,12 @@
    }
    final long[] selectedIDs;
    final int count = 1 + beforeCount + afterCount;
    // Never allocate more longs than the number of entries actually available
    // from startPos, and compute the size with long arithmetic so an
    // attacker-supplied before/after count cannot overflow or drive an oversized
    // array (GHSA-q4wx-wj4j-4657).
    final int available = startPos >= 0 && startPos < currentCount ? currentCount - startPos : 0;
    final int count = (int) Math.max(0L, Math.min(1L + beforeCount + afterCount, available));
    try (Cursor<ByteString, ByteString> cursor = txn.openCursor(getName()))
    {
      if (cursor.positionToIndex(startPos))
opendj-server-legacy/src/main/java/org/opends/server/controls/VLVRequestControl.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2008 Sun Microsystems, Inc.
 * Portions Copyright 2014-2016 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC
 */
package org.opends.server.controls;
import org.forgerock.i18n.LocalizableMessage;
@@ -74,6 +75,16 @@
        int beforeCount = (int)reader.readInteger();
        int afterCount  = (int)reader.readInteger();
        // The VLV draft defines beforeCount/afterCount as INTEGER (0..maxInt).
        // Reject negative values (including those produced by the (int) cast
        // wrapping a wire value above 2^31-1): unchecked they would flow into an
        // unbounded / overflowing array allocation downstream (GHSA-q4wx-wj4j-4657).
        if (beforeCount < 0 || afterCount < 0)
        {
          throw new DirectoryException(ResultCode.PROTOCOL_ERROR,
              INFO_VLVREQ_CONTROL_CANNOT_DECODE_VALUE.get("beforeCount and afterCount must not be negative"));
        }
        int offset = 0;
        int contentCount = 0;
        ByteString greaterThanOrEqual = null;
@@ -85,6 +96,11 @@
            offset = (int)reader.readInteger();
            contentCount = (int)reader.readInteger();
            reader.readEndSequence();
            if (offset < 0 || contentCount < 0)
            {
              throw new DirectoryException(ResultCode.PROTOCOL_ERROR,
                  INFO_VLVREQ_CONTROL_CANNOT_DECODE_VALUE.get("offset and contentCount must not be negative"));
            }
            break;
          case TYPE_TARGET_GREATERTHANOREQUAL:
opendj-server-legacy/src/main/java/org/opends/server/extensions/PlainSASLMechanismHandler.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2006-2009 Sun Microsystems, Inc.
 * Portions Copyright 2011-2016 ForgeRock AS.
 * Portions Copyrighted 2026 3A Systems, LLC.
 */
package org.opends.server.extensions;
@@ -330,6 +331,11 @@
              return;
            }
          }
          if (! checkProxyAccess(bindOperation, userEntry, authZEntry))
          {
            return;
          }
        }
      }
      else
@@ -391,6 +397,11 @@
            bindOperation.setAuthFailureReason(message);
            return;
          }
          if (! checkProxyAccess(bindOperation, userEntry, authZEntry))
          {
            return;
          }
        }
      }
    }
@@ -444,6 +455,37 @@
    return;
  }
  /**
   * Checks whether the authenticated user is allowed by the access control
   * subsystem to assume the given authorization identity (i.e. holds the
   * "proxy" access control right for the target entry), and if not sets the
   * bind failure result. This delegates to {@link SASLContext#hasProxyAccess}
   * so that SASL PLAIN enforces the same ACI scope check as the proxied
   * authorization controls and the DIGEST-MD5 / GSSAPI {@code authzid}, in
   * addition to the {@code PROXIED_AUTH} privilege check. The denial uses the
   * same {@code INVALID_CREDENTIALS} result and message as DIGEST-MD5 / GSSAPI
   * so a client cannot distinguish a missing privilege from a missing proxy
   * grant.
   *
   * @param  bindOperation  The bind operation being processed.
   * @param  authEntry      The authenticated user entry (the proxy user).
   * @param  authZEntry     The entry to be assumed, or {@code null} for the
   *                        anonymous / root authorization identity.
   * @return  {@code true} if the access control configuration permits the
   *          authenticated user to assume the authorization identity.
   */
  private boolean checkProxyAccess(BindOperation bindOperation, Entry authEntry, Entry authZEntry)
  {
    if (! SASLContext.hasProxyAccess(authEntry, authZEntry, bindOperation))
    {
      bindOperation.setResultCode(ResultCode.INVALID_CREDENTIALS);
      bindOperation.setAuthFailureReason(
          ERR_SASL_AUTHZID_INSUFFICIENT_ACCESS.get(authEntry.getName()));
      return false;
    }
    return true;
  }
  @Override
  public boolean isPasswordBased(String mechanism)
  {
opendj-server-legacy/src/main/java/org/opends/server/extensions/SASLContext.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2008-2009 Sun Microsystems, Inc.
 * Portions Copyright 2011-2016 ForgeRock AS.
 * Portions Copyrighted 2026 3A Systems, LLC.
 */
package org.opends.server.extensions;
@@ -58,6 +59,7 @@
import org.opends.server.types.AuthenticationInfo;
import org.opends.server.types.DirectoryException;
import org.opends.server.types.Entry;
import org.opends.server.types.Operation;
import org.opends.server.types.Privilege;
/**
@@ -927,30 +929,54 @@
   */
  private boolean hasPermission(final AuthenticationInfo authInfo)
  {
    boolean ret = true;
    Entry e = authzEntry;
    // If the authz entry is null, use the entry associated with the NULL DN.
    if (e == null)
    if (!hasProxyAccess(authInfo.getAuthenticationEntry(), authzEntry, bindOp))
    {
      setCallbackMsg(ERR_SASL_AUTHZID_INSUFFICIENT_ACCESS.get(authEntry.getName()));
      return false;
    }
    return true;
  }
  /**
   * Determines whether the access control configuration permits the
   * authenticated user to assume the given authorization identity, i.e. holds
   * the "proxy" access control right for the target entry. A {@code null}
   * authorization entry maps to the entry associated with the NULL DN (the
   * anonymous / root authorization identity). This is the shared SASL
   * proxy-scope check used by DIGEST-MD5 / GSSAPI and by SASL PLAIN.
   *
   * @param authEntry
   *          The authenticated user entry (the proxy user).
   * @param authzEntry
   *          The entry to be assumed, or {@code null} for the anonymous / root
   *          authorization identity.
   * @param operation
   *          The operation being processed.
   * @return {@code true} if the authenticated user may assume the authorization
   *         identity.
   */
  static boolean hasProxyAccess(final Entry authEntry, final Entry authzEntry,
      final Operation operation)
  {
    Entry proxiedEntry = authzEntry;
    if (proxiedEntry == null)
    {
      // A null authorization entry maps to the entry associated with the NULL DN.
      try
      {
        e = DirectoryServer.getEntry(DN.rootDN());
        proxiedEntry = DirectoryServer.getEntry(DN.rootDN());
      }
      catch (final DirectoryException ex)
      catch (final DirectoryException e)
      {
        logger.traceException(e);
        return false;
      }
    }
    if (!AccessControlConfigManager.getInstance().getAccessControlHandler()
        .mayProxy(authInfo.getAuthenticationEntry(), e, bindOp))
    {
      setCallbackMsg(ERR_SASL_AUTHZID_INSUFFICIENT_ACCESS.get(authEntry.getName()));
      ret = false;
    }
    return ret;
    return AccessControlConfigManager.getInstance().getAccessControlHandler()
        .mayProxy(authEntry, proxiedEntry, operation);
  }
opendj-server-legacy/src/main/java/org/opends/server/protocols/jmx/RmiConnector.java
@@ -71,10 +71,8 @@
  /**
   * JDK 10+ JMX environment property scoping a JEP 290 deserialization
   * filter to the credentials object passed during {@code newClient()}.
   * Using the credentials-scoped filter (instead of the connector-wide
   * {@code jmx.remote.rmi.server.serial.filter.pattern}) avoids breaking
   * legitimate JMX traffic such as MBean invocations and notifications,
   * which may legitimately carry non-String types.
   * It is the tightest of the two filters configured below (it accepts only
   * the two-element {@code String[]} used for SASL/PLAIN authentication).
   * <p>
   * Note: this property is mutually exclusive with
   * {@code jmx.remote.rmi.server.credential.types}; specifying both makes
@@ -91,6 +89,38 @@
  /**
   * JDK 10+ JMX environment property installing a JEP 290 deserialization
   * filter for the whole RMI connection, in particular the arguments of MBean
   * operations ({@code invoke}) and attribute values ({@code setAttribute}).
   * Unlike {@link #JMX_REMOTE_RMI_SERVER_CREDENTIALS_FILTER_PATTERN}, which
   * only guards the credentials object exchanged during authentication, this
   * filter closes the post-authentication deserialization surface: without it,
   * an authenticated client could drive native deserialization of arbitrary
   * object graphs through MBean operation arguments
   * (advisory GHSA-qj63-3vrg-vcfx, incomplete fix of CVE-2026-46495).
   */
  static final String JMX_REMOTE_RMI_SERVER_SERIAL_FILTER_PATTERN =
      "jmx.remote.rmi.server.serial.filter.pattern";
  /**
   * Allowlist of the JDK and JMX management types OpenDJ legitimately receives
   * as MBean operation arguments / attribute values, rejecting everything else
   * via the trailing {@code !*}. OpenDJ exposes read-only configuration and
   * monitoring attributes and no MBean operations, so the only types that need
   * to cross this filter are strings, primitive wrappers and the standard
   * {@code javax.management} request types ({@code ObjectName},
   * {@code Attribute}, {@code AttributeList}, {@code QueryExp}, ...).
   */
  private static final String JMX_OPERATION_SERIAL_FILTER =
      "maxdepth=20;"
      + "java.lang.*;java.math.BigInteger;java.math.BigDecimal;"
      + "java.util.*;"
      + "javax.management.*;javax.management.openmbean.*;"
      + "!*";
  /**
   * The MBean server used to handle JMX interaction.
   */
  private MBeanServer mbs;
@@ -398,15 +428,23 @@
  static void configureJmxDeserializationProtection(Map<String, Object> env)
  {
    // Scope the JEP 290 deserialization filter to the credentials object
    // only, so legitimate JMX RMI traffic (MBean operations, notifications,
    // etc.) is not affected by the restrictive allowlist.
    // Tightly constrain the credentials object exchanged during authentication
    // (CVE-2026-46495): only a two-element String[] is accepted.
    //
    // Do NOT also set "jmx.remote.rmi.server.credential.types": the JDK
    // rejects an environment that defines both properties, which would
    // prevent the RMI connector from starting.
    env.put(JMX_REMOTE_RMI_SERVER_CREDENTIALS_FILTER_PATTERN,
        JMX_CREDENTIAL_SERIAL_FILTER);
    // Additionally constrain the rest of the RMI connection -- in particular
    // MBean operation arguments and attribute values, which are deserialized
    // after authentication -- to a small allowlist of JDK and JMX management
    // types. This closes the post-authentication deserialization surface left
    // open by the credentials-only filter
    // (GHSA-qj63-3vrg-vcfx, incomplete fix of CVE-2026-46495).
    env.put(JMX_REMOTE_RMI_SERVER_SERIAL_FILTER_PATTERN,
        JMX_OPERATION_SERIAL_FILTER);
  }
  /**
opendj-server-legacy/src/main/java/org/opends/server/protocols/ldap/LDAPRequestHandler.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2006-2010 Sun Microsystems, Inc.
 * Portions Copyright 2014-2016 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC
 */
package org.opends.server.protocols.ldap;
@@ -192,6 +193,16 @@
          readyConnection.disconnect(DisconnectReason.PROTOCOL_ERROR, true,
            e.getMessageObject());
        }
        catch (StackOverflowError e)
        {
          // Defense in depth for over-nested protocol elements (e.g. search
          // filters - GHSA-rv4q-c6mr-wxp7). A StackOverflowError is an Error,
          // not an Exception, so without this it would escape and kill the
          // shared request-handler thread. Drop only the offending connection.
          logger.traceException(e);
          readyConnection.disconnect(DisconnectReason.PROTOCOL_ERROR, true,
            LocalizableMessage.raw(e.toString()));
        }
        catch (Exception e)
        {
          logger.traceException(e);
opendj-server-legacy/src/main/java/org/opends/server/types/RawFilter.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2006-2009 Sun Microsystems, Inc.
 * Portions Copyright 2013-2015 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC
 */
package org.opends.server.types;
@@ -31,6 +32,7 @@
import static org.opends.messages.ProtocolMessages.*;
import static org.opends.server.protocols.ldap.LDAPConstants.*;
import static org.opends.server.protocols.ldap.LDAPResultCode.*;
import static org.opends.server.util.ServerConstants.MAX_NESTED_FILTER_DEPTH;
import static org.opends.server.util.StaticUtils.*;
@@ -531,6 +533,42 @@
  public static LDAPFilter decode(ASN1Reader reader)
         throws LDAPException
  {
    return decode(reader, 0);
  }
  /**
   * Decodes the elements from the provided ASN.1 reader as a raw
   * search filter, tracking the current nesting depth.
   * <p>
   * The {@code depth} guard bounds the recursion so that a maliciously
   * over-nested AND/OR/NOT filter is rejected with a
   * {@code PROTOCOL_ERROR} instead of overflowing the JVM stack with a
   * {@code StackOverflowError} (GHSA-rv4q-c6mr-wxp7). Without it an
   * unauthenticated client could kill the request-handler thread, since
   * a {@code StackOverflowError} is an {@code Error} and escapes the
   * {@code catch (Exception)} blocks on the decode path.
   *
   * @param  reader The ASN.1 reader.
   * @param  depth  The current filter nesting depth (0 for the top-level
   *                filter).
   *
   * @return  The decoded search filter.
   *
   * @throws  LDAPException  If the provided ASN.1 element cannot be
   *                         decoded as a raw search filter, or if it is
   *                         nested more deeply than
   *                         {@link org.opends.server.util.ServerConstants#MAX_NESTED_FILTER_DEPTH}.
   */
  private static LDAPFilter decode(ASN1Reader reader, int depth)
         throws LDAPException
  {
    if (depth >= MAX_NESTED_FILTER_DEPTH)
    {
      LocalizableMessage message =
          ERR_LDAP_FILTER_DECODE_MAX_NESTING_DEPTH.get(MAX_NESTED_FILTER_DEPTH);
      throw new LDAPException(PROTOCOL_ERROR, message);
    }
    byte type;
    try
    {
@@ -546,10 +584,10 @@
    {
      case TYPE_FILTER_AND:
      case TYPE_FILTER_OR:
        return decodeCompoundFilter(reader);
        return decodeCompoundFilter(reader, depth);
      case TYPE_FILTER_NOT:
        return decodeNotFilter(reader);
        return decodeNotFilter(reader, depth);
      case TYPE_FILTER_EQUALITY:
      case TYPE_FILTER_GREATER_OR_EQUAL:
@@ -586,7 +624,7 @@
   *                         decode the provided ASN.1 element as a
   *                         raw search filter.
   */
  private static LDAPFilter decodeCompoundFilter(ASN1Reader reader)
  private static LDAPFilter decodeCompoundFilter(ASN1Reader reader, int depth)
      throws LDAPException
  {
    byte type;
@@ -627,7 +665,7 @@
      // could also be an absolute true/false filter
      while (reader.hasNextElement())
      {
        filterComponents.add(LDAPFilter.decode(reader));
        filterComponents.add(decode(reader, depth + 1));
      }
      reader.readEndSequence();
    }
@@ -659,14 +697,14 @@
   *                         decode the provided ASN.1 element as a
   *                         raw search filter.
   */
  private static LDAPFilter decodeNotFilter(ASN1Reader reader)
  private static LDAPFilter decodeNotFilter(ASN1Reader reader, int depth)
          throws LDAPException
  {
    RawFilter notComponent;
    try
    {
      reader.readStartSequence();
      notComponent = decode(reader);
      notComponent = decode(reader, depth + 1);
      reader.readEndSequence();
    }
    catch (LDAPException le)
opendj-server-legacy/src/messages/org/opends/messages/protocol.properties
@@ -262,6 +262,9 @@
ERR_LDAP_FILTER_DECODE_NOT_COMPONENT_143=Cannot decode the provided \
 ASN.1 element as an LDAP search filter because the NOT component element \
 could not be decoded as an LDAP filter: %s
ERR_LDAP_FILTER_DECODE_MAX_NESTING_DEPTH_1538=Cannot decode the provided \
 ASN.1 element as an LDAP search filter because it is nested more than %d \
 levels deep, which exceeds the maximum allowed filter nesting depth
ERR_LDAP_FILTER_DECODE_TV_SEQUENCE_144=Cannot decode the provided ASN.1 \
 element as an LDAP search filter because the element could not be decoded as \
 a type-and-value sequence: %s
opendj-server-legacy/src/test/java/org/opends/server/backends/ChangelogBackendTestCase.java
@@ -145,6 +145,9 @@
  private final List<LDAPReplicationDomain> domains = new ArrayList<>();
  private final Map<ReplicaId, ReplicationBroker> brokers = new HashMap<>();
  /** Time of the last CSN built by {@link #generateCSNs(int, ReplicaId)}, to keep batches monotonic. */
  private long lastGeneratedCsnTime;
  @BeforeClass
  @Override
  public void setUp() throws Exception
@@ -1252,7 +1255,10 @@
  private CSN[] generateCSNs(int numberOfCsns, ReplicaId replicaId)
  {
    long startTime = TimeThread.getTime();
    // TimeThread only refreshes its cached time every 200 ms, so two batches generated within one
    // tick would collide and be filtered out by the changelog as breaking the key ordering.
    // Start after the previous batch to keep CSNs monotonically increasing, like CSNGenerator does.
    long startTime = Math.max(TimeThread.getTime(), lastGeneratedCsnTime + 1);
    CSN[] csns = new CSN[numberOfCsns];
    for (int i = 0; i < numberOfCsns; i++)
@@ -1260,6 +1266,7 @@
      // seqNum must be greater than 0, so start at 1
      csns[i] = new CSN(startTime + i, i + 1, replicaId.getServerId());
    }
    lastGeneratedCsnTime = csns[numberOfCsns - 1].getTime();
    return csns;
  }
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/EncryptedTestCase.java
@@ -11,7 +11,7 @@
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2023-2024 3A Systems, LLC.
 * Copyright 2023-2026 3A Systems, LLC.
 */
package org.opends.server.backends.jdbc;
@@ -46,6 +46,7 @@
            container.start();
        }
        try(Connection con= DriverManager.getConnection(createBackendCfg().getDBDirectory())){
            TestCase.dropStaleTrees(con);
        } catch (Exception e) {
            throw new SkipException("run before test: docker run --rm -it -p 5432:5432 -e POSTGRES_DB=database_name -e POSTGRES_PASSWORD=password --name postgres postgres");
        }
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java
@@ -11,23 +11,43 @@
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2024-2025 3A Systems, LLC.
 * Copyright 2024-2026 3A Systems, LLC.
 */
package org.opends.server.backends.jdbc;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.server.config.server.JDBCBackendCfg;
import org.opends.server.backends.pluggable.PluggableBackendImplTestCase;
import org.opends.server.backends.pluggable.spi.AccessMode;
import org.opends.server.backends.pluggable.spi.Cursor;
import org.opends.server.backends.pluggable.spi.ReadOperation;
import org.opends.server.backends.pluggable.spi.ReadableTransaction;
import org.opends.server.backends.pluggable.spi.TreeName;
import org.opends.server.backends.pluggable.spi.WriteOperation;
import org.opends.server.backends.pluggable.spi.WriteableTransaction;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.containers.JdbcDatabaseContainer;
import org.testng.SkipException;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.NoSuchElementException;
import static org.forgerock.opendj.config.ConfigurationMock.mockCfg;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNull;
import static org.testng.Assert.assertTrue;
import static org.testng.Assert.fail;
@@ -50,14 +70,35 @@
                throw new SkipException(getContainerDockerCommand());
            }
        }
        try(Connection ignored = DriverManager.getConnection(createBackendCfg().getDBDirectory())){
        try(Connection con = DriverManager.getConnection(createBackendCfg().getDBDirectory())){
            dropStaleTrees(con);
        } catch (Exception e) {
            throw new SkipException(getContainerDockerCommand());
        }
        super.setUp();
    }
    /**
     * Backend test classes sharing one database map the same tree names to the same tables,
     * so a previous run may leave trees behind — including entries encrypted with a lost cipher key.
     */
    static void dropStaleTrees(Connection con) throws SQLException {
        final List<String> stale = new ArrayList<>();
        try (final ResultSet rs = con.getMetaData().getTables(null, null, null, new String[]{"TABLE"})) {
            while (rs.next()) {
                final String name = rs.getString("TABLE_NAME");
                if (name.toLowerCase().startsWith("opendj_")) {
                    stale.add(name);
                }
            }
        }
        try (final Statement st = con.createStatement()) {
            for (final String name : stale) {
                st.execute("drop table " + name);
            }
        }
    }
    @Override
    protected Backend createBackend() {
        return new Backend();
@@ -87,4 +128,113 @@
    protected abstract String getBackendId();
    protected abstract String getJdbcUrl();
    private static ByteString key(int i) {
        return ByteString.valueOfUtf8(String.format("key%02d", i));
    }
    private static ByteString value(int i) {
        return ByteString.valueOfUtf8("value" + i);
    }
    /** Cursor operations must keep working when the tree spans several "fetchsize" batches. */
    @Test
    public void testCursorCrossesFetchSizeBatches() throws Exception {
        System.setProperty("org.openidentityplatform.opendj.jdbc.fetchsize", "2");
        final Storage storage = new Storage(createBackendCfg(), null);
        final TreeName tree = new TreeName("testCursorBatch", "tree");
        try {
            storage.open(AccessMode.READ_WRITE);
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                    for (int i = 0; i < 7; i++) {
                        txn.put(tree, key(i), value(i));
                    }
                }
            });
            storage.read(new ReadOperation<Void>() {
                @Override
                public Void run(ReadableTransaction txn) throws Exception {
                    try (final Cursor<ByteString, ByteString> cursor = txn.openCursor(tree)) {
                        for (int i = 0; i < 7; i++) {
                            assertTrue(cursor.next(), "next() at " + i);
                            assertEquals(cursor.getKey(), key(i));
                            assertEquals(cursor.getValue(), value(i));
                        }
                        assertFalse(cursor.next());
                        assertFalse(cursor.isDefined());
                        try {
                            cursor.getKey();
                            fail("getKey() on undefined cursor must fail");
                        } catch (NoSuchElementException expected) {}
                        assertTrue(cursor.positionToKeyOrNext(key(3)));
                        assertEquals(cursor.getKey(), key(3));
                        assertTrue(cursor.positionToKeyOrNext(ByteString.valueOfUtf8("key031")));
                        assertEquals(cursor.getKey(), key(4));
                        assertTrue(cursor.next());
                        assertEquals(cursor.getKey(), key(5));
                        assertTrue(cursor.next());
                        assertEquals(cursor.getKey(), key(6));
                        assertFalse(cursor.next());
                        assertFalse(cursor.positionToKeyOrNext(ByteString.valueOfUtf8("z")));
                        assertFalse(cursor.isDefined());
                        assertTrue(cursor.positionToKey(key(5)));
                        assertEquals(cursor.getValue(), value(5));
                        assertTrue(cursor.next());
                        assertEquals(cursor.getKey(), key(6));
                        assertFalse(cursor.positionToKey(ByteString.valueOfUtf8("key99")));
                        assertFalse(cursor.isDefined());
                        assertTrue(cursor.positionToIndex(0));
                        assertEquals(cursor.getKey(), key(0));
                        assertTrue(cursor.positionToIndex(5));
                        assertEquals(cursor.getKey(), key(5));
                        assertTrue(cursor.next());
                        assertEquals(cursor.getKey(), key(6));
                        assertFalse(cursor.positionToIndex(7));
                        assertFalse(cursor.positionToIndex(-1));
                        assertTrue(cursor.positionToLastKey());
                        assertEquals(cursor.getKey(), key(6));
                        assertFalse(cursor.next());
                        assertTrue(cursor.positionToKey(key(0)));
                        try {
                            cursor.delete();
                            fail("delete() on read-only cursor must fail");
                        } catch (UnsupportedOperationException expected) {}
                    }
                    return null;
                }
            });
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    try (final Cursor<ByteString, ByteString> cursor = txn.openCursor(tree)) {
                        assertTrue(cursor.positionToKey(key(3)));
                        cursor.delete();
                        assertTrue(cursor.next());
                        assertEquals(cursor.getKey(), key(4));
                    }
                    assertNull(txn.read(tree, key(3)));
                    assertEquals(txn.getRecordCount(tree), 6);
                }
            });
        } finally {
            System.clearProperty("org.openidentityplatform.opendj.jdbc.fetchsize");
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.deleteTree(tree);
                    }
                });
            } catch (Exception ignored) {}
            storage.close();
        }
    }
}
opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PluggableBackendImplTestCase.java
@@ -1217,7 +1217,9 @@
              "objectClasses: ( 16.32.256.1.1.1.12.4.6 NAME 'examplePerson' DESC 'Extension to person' SUP inetOrgPerson STRUCTURAL MUST ( exampleIdentifier ) MAY ( exampleEmails $ userAccountControl ) X-SCHEMA-FILE '999-user.ldif' )",
              ""
            );
    assertEquals(resultCode, 0);
    // 20 = attributeOrValueExists: another backend test class already added these definitions
    // to the schema of the shared in-JVM server
    assertTrue(resultCode == 0 || resultCode == 20, "unexpected result code " + resultCode);
    TestCaseUtils.addEntries(
            Resources.readLines(Resources.getResource("issue496.ldif"), StandardCharsets.UTF_8).toArray(new String[]{})
    );
opendj-server-legacy/src/test/java/org/opends/server/controls/VLVOffsetAllocationDoSTest.java
New file
@@ -0,0 +1,126 @@
/*
 * 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.server.controls;
import static org.opends.server.protocols.internal.InternalClientConnection.*;
import static org.opends.server.protocols.internal.Requests.*;
import static org.testng.Assert.*;
import org.forgerock.opendj.ldap.ResultCode;
import org.forgerock.opendj.ldap.SearchScope;
import org.opends.server.TestCaseUtils;
import org.opends.server.protocols.internal.InternalSearchOperation;
import org.opends.server.protocols.internal.SearchRequest;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
/**
 * Regression test for GHSA-q4wx-wj4j-4657 / OPENDJ-003 — unbounded array
 * allocation via VLV beforeCount/afterCount/offset (CWE-789 / CWE-770 / CWE-190).
 * <p>
 * A VLV-by-offset request carries attacker-controlled before/after counts. The
 * server computed {@code count = 1 + beforeCount + afterCount} and allocated
 * {@code new long[count]} without clamping it to the actual list size. With
 * {@code afterCount = Integer.MAX_VALUE} the sum overflowed to a negative int,
 * yielding a {@code NegativeArraySizeException} (or, with large non-overflowing
 * values, a multi-gigabyte allocation / {@code OutOfMemoryError}) from a single
 * search request. The fix computes the count with long arithmetic and clamps it
 * to the number of entries actually available, so the oversized window is now
 * bounded instead of faulting the search handler.
 * <p>
 * Note: this exercises the default in-memory sort path
 * ({@code EntryContainer.sortByOffset}), which requires no VLV index — the same
 * unclamped allocation also existed in {@code VLVIndex.readRange}.
 */
@SuppressWarnings("javadoc")
public class VLVOffsetAllocationDoSTest extends ControlsTestCase
{
  @BeforeClass
  public void startServer() throws Exception
  {
    TestCaseUtils.startServer();
  }
  private void populateDB() throws Exception
  {
    TestCaseUtils.clearBackend("userRoot", "dc=example,dc=com");
    TestCaseUtils.addEntries(
        "dn: uid=albert.zimmerman,dc=example,dc=com",
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: albert.zimmerman",
        "givenName: Albert",
        "sn: Zimmerman",
        "cn: Albert Zimmerman",
        "",
        "dn: uid=aaron.zimmerman,dc=example,dc=com",
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: aaron.zimmerman",
        "givenName: Aaron",
        "sn: Zimmerman",
        "cn: Aaron Zimmerman",
        "",
        "dn: uid=mary.jones,dc=example,dc=com",
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "uid: mary.jones",
        "givenName: Mary",
        "sn: Jones",
        "cn: Mary Jones");
  }
  /**
   * A single VLV-by-offset search with {@code afterCount = Integer.MAX_VALUE}
   * (which previously overflowed {@code 1 + beforeCount + afterCount} into a
   * negative array size) must now be clamped to the list size and return the
   * same bounded page as a normal request — not fault the search handler with a
   * {@code NegativeArraySizeException} / {@code OTHER}.
   */
  @Test
  public void offsetAllocationOverflowIsClamped() throws Exception
  {
    populateDB();
    // Baseline: a normal, bounded window.
    SearchRequest ok = newSearchRequest("dc=example,dc=com", SearchScope.WHOLE_SUBTREE, "(objectClass=person)")
        .addControl(new ServerSideSortRequestControl("givenName"))
        .addControl(new VLVRequestControl(0, 3, 1, 0));
    InternalSearchOperation okOp = getRootConnection().processSearch(ok);
    assertEquals(okOp.getResultCode(), ResultCode.SUCCESS);
    // Oversized window: afterCount = Integer.MAX_VALUE. Before the fix this
    // overflowed to new long[-2147483648]; a larger non-overflowing count would
    // instead force a multi-gigabyte allocation (OutOfMemoryError).
    SearchRequest bad = newSearchRequest("dc=example,dc=com", SearchScope.WHOLE_SUBTREE, "(objectClass=person)")
        .addControl(new ServerSideSortRequestControl("givenName"))
        .addControl(new VLVRequestControl(0, Integer.MAX_VALUE, 1, 0));
    InternalSearchOperation badOp = getRootConnection().processSearch(bad);
    assertEquals(badOp.getResultCode(), ResultCode.SUCCESS,
        "oversized VLV afterCount must be clamped to the list size, not fault the search handler "
            + "(GHSA-q4wx-wj4j-4657): " + badOp.getErrorMessage());
    // The page is bounded by the number of entries that actually exist (offset 1
    // onwards), i.e. the same result as the bounded baseline request.
    assertEquals(badOp.getSearchEntries().size(), okOp.getSearchEntries().size());
  }
}
opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/JmxMBeanArgDeserializationTest.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.server.protocols.jmx;
import static org.opends.server.protocols.internal.InternalClientConnection.*;
import static org.testng.Assert.*;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
import javax.management.MBeanServer;
import javax.management.MBeanServerConnection;
import javax.management.ObjectName;
import org.forgerock.opendj.ldap.DN;
import org.forgerock.opendj.ldap.ResultCode;
import org.opends.server.TestCaseUtils;
import org.opends.server.core.DeleteOperation;
import org.opends.server.core.DirectoryServer;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
/**
 * Regression test for GHSA-qj63-3vrg-vcfx / OPENDJ-002 — incomplete fix of
 * CVE-2026-46495.
 * <p>
 * The CVE-2026-46495 fix scoped its JEP 290 deserialization filter to the
 * {@code credentials} object only
 * ({@code jmx.remote.rmi.server.credentials.filter.pattern}), leaving the
 * arguments of MBean operations ({@code Object[] params}) to be unmarshalled
 * after authentication with no serial filter at all. The follow-up fix also
 * installs the connector-wide
 * {@code jmx.remote.rmi.server.serial.filter.pattern}, restricting MBean
 * operation arguments to a small allowlist of JDK / JMX management types.
 * <p>
 * This test exercises the surface end-to-end. It:
 * <ol>
 * <li>registers a do-nothing target MBean in the server's MBean server (so the
 *     JDK's {@code RMIConnectionImpl.invoke} passes its {@code getClassLoaderFor}
 *     check and reaches the argument-unmarshalling step with a class loader that
 *     can see the canary type);</li>
 * <li>authenticates over JMX/RMI as a user holding {@code JMX_READ};</li>
 * <li>invokes an operation passing a custom {@link Serializable} "canary" as an
 *     argument.</li>
 * </ol>
 * With the fix in place the connector-wide serial filter rejects the canary
 * type during unmarshalling: the call fails with a filter rejection and the
 * canary's {@code readObject()} never runs. Because the test server runs
 * in-process, that server-side deserialization (had it happened) would have
 * flipped a static flag the test observes directly. Before the fix this test
 * fails (the flag is set), which is exactly the vulnerability it guards
 * against.
 */
@SuppressWarnings("javadoc")
@Test(groups = { "precommit", "jmx" }, sequential = true)
public class JmxMBeanArgDeserializationTest extends JmxTestCase
{
  /** DN of the JMX_READ user created for this test. */
  private static final String JMX_USER_DN = "cn=Privileged JMX User,o=test";
  /** ObjectName of the helper target MBean registered for the duration of the test. */
  private static final String TARGET_MBEAN = "org.opends.server.test:type=JmxDeserCanaryTarget";
  /** Minimal Standard MBean used only as a registered {@code invoke()} target. */
  public interface CanaryTargetMBean
  {
    // No attributes or operations: the operation we invoke deliberately does
    // not exist, so dispatch fails -- but only after the arguments have been
    // deserialized.
  }
  /** Loaded by the test/app class loader, which can resolve {@link DeserializationCanary}. */
  public static final class CanaryTarget implements CanaryTargetMBean
  {
  }
  /**
   * A serializable marker whose custom {@code readObject} records that it was
   * deserialized. It stands in for an attacker-supplied gadget: a connector-wide
   * serial filter restricting MBean argument types to the small set OpenDJ
   * actually accepts would reject this class before {@code readObject} ever ran.
   */
  public static final class DeserializationCanary implements Serializable
  {
    private static final long serialVersionUID = 1L;
    /** Set true server-side when this object is unmarshalled. */
    static volatile boolean deserialized;
    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException
    {
      in.defaultReadObject();
      deserialized = true;
    }
  }
  @Override
  @BeforeClass
  public void setUp() throws Exception
  {
    super.setUp();
    TestCaseUtils.addEntries(
        "dn: " + JMX_USER_DN,
        "objectClass: top",
        "objectClass: person",
        "objectClass: organizationalPerson",
        "objectClass: inetOrgPerson",
        "cn: Privileged JMX User",
        "givenName: Privileged",
        "sn: User",
        "uid: privileged.jmx.user",
        "userPassword: password",
        "ds-privilege-name: jmx-read",
        "ds-privilege-name: jmx-write",
        "ds-pwp-password-policy-dn: cn=Clear UserPassword Policy,cn=Password Policies,cn=config");
  }
  @AfterClass
  public void afterClass() throws Exception
  {
    DeleteOperation deleteOperation = getRootConnection().processDelete(DN.valueOf(JMX_USER_DN));
    assertEquals(deleteOperation.getResultCode(), ResultCode.SUCCESS);
  }
  /**
   * An authenticated client's MBean operation argument of an arbitrary type is
   * rejected by the connector-wide serial filter during unmarshalling, before
   * its {@code readObject()} can run.
   */
  @Test
  public void mbeanOperationArgumentsAreFilteredBeforeDeserialization() throws Exception
  {
    DeserializationCanary.deserialized = false;
    MBeanServer serverMBeans = DirectoryServer.getJMXMBeanServer();
    ObjectName target = new ObjectName(TARGET_MBEAN);
    if (serverMBeans.isRegistered(target))
    {
      serverMBeans.unregisterMBean(target);
    }
    serverMBeans.registerMBean(new CanaryTarget(), target);
    Exception thrown = null;
    OpendsJmxConnector connector = connect(JMX_USER_DN, "password", TestCaseUtils.getServerJmxPort());
    assertNotNull(connector, "JMX_READ user should be able to authenticate to the JMX connector");
    try
    {
      MBeanServerConnection mbsc = connector.getMBeanServerConnection();
      // getClassLoaderFor(target) succeeds (target is registered), so the call
      // reaches argument unmarshalling. The connector-wide serial filter must
      // reject the canary type there, before readObject() runs.
      try
      {
        mbsc.invoke(target,
            "thisOperationDoesNotExist",
            new Object[] { new DeserializationCanary() },
            new String[] { "java.lang.Object" });
        fail("Expected the non-allowlisted argument to be rejected by the serial filter");
      }
      catch (Exception expected)
      {
        thrown = expected;
      }
    }
    finally
    {
      connector.close();
      if (serverMBeans.isRegistered(target))
      {
        serverMBeans.unregisterMBean(target);
      }
    }
    assertFalse(DeserializationCanary.deserialized,
        "VULNERABLE (GHSA-qj63-3vrg-vcfx): an authenticated client's MBean operation argument "
            + "was deserialized server-side. The connector-wide serial filter "
            + "(jmx.remote.rmi.server.serial.filter.pattern) must reject non-allowlisted types "
            + "before readObject() runs.");
    assertTrue(isSerialFilterRejection(thrown),
        "Expected a JEP 290 serial-filter rejection of the argument, but got: " + thrown);
  }
  /** True if the throwable chain shows a JEP 290 serial-filter rejection. */
  private static boolean isSerialFilterRejection(Throwable t)
  {
    for (Throwable c = t; c != null; c = c.getCause())
    {
      if (c instanceof java.io.InvalidClassException)
      {
        return true;
      }
      String message = c.getMessage();
      if (message != null
          && (message.contains("REJECTED") || message.contains("filter status")
              || message.contains("serial filter")))
      {
        return true;
      }
    }
    return false;
  }
  /** Connects to the in-process JMX/RMI connector with the given credentials. */
  private OpendsJmxConnector connect(String user, String password, int jmxPort) throws IOException
  {
    Map<String, Object> env = new HashMap<>();
    env.put("jmx.remote.credentials", new String[] { user, password });
    env.put("jmx.remote.x.client.connection.check.period", 0);
    try
    {
      OpendsJmxConnector connector = new OpendsJmxConnector("localhost", jmxPort, env);
      connector.connect();
      return connector;
    }
    catch (SecurityException | IOException e)
    {
      return null;
    }
  }
}
opendj-server-legacy/src/test/java/org/opends/server/protocols/jmx/RmiAuthenticatorTest.java
@@ -27,6 +27,8 @@
import java.util.HashMap;
import java.util.Map;
import javax.management.Attribute;
import javax.management.ObjectName;
import org.opends.server.DirectoryServerTestCase;
import org.testng.annotations.DataProvider;
@@ -69,15 +71,47 @@
    assertEquals(env.get(RmiConnector.JMX_REMOTE_RMI_SERVER_CREDENTIALS_FILTER_PATTERN),
        "maxdepth=3;maxarray=2;java.lang.String;!*");
    // The connector-wide filter must NOT be set, so legitimate JMX traffic
    // (MBean operations, notifications) is not affected by the allowlist.
    assertNull(env.get("jmx.remote.rmi.server.serial.filter.pattern"));
    // The connector-wide filter must also be set so that post-authentication
    // traffic (MBean operation arguments, attribute values) is constrained to a
    // safe allowlist instead of being deserialized without any filter
    // (incomplete fix of CVE-2026-46495).
    assertNotNull(env.get(RmiConnector.JMX_REMOTE_RMI_SERVER_SERIAL_FILTER_PATTERN));
    // "jmx.remote.rmi.server.credential.types" is mutually exclusive with the
    // credentials filter pattern: setting both prevents the connector from
    // starting, so only the filter pattern must be configured.
    assertNull(env.get("jmx.remote.rmi.server.credential.types"));
  }
  /**
   * Verifies the connector-wide operation filter allows the JDK / JMX
   * management types OpenDJ legitimately receives, while rejecting arbitrary
   * (potentially gadget) classes.
   */
  @Test
  public void operationSerialFilterAllowsManagementTypesAndRejectsOthers() throws Exception
  {
    Map<String, Object> env = new HashMap<>();
    RmiConnector.configureJmxDeserializationProtection(env);
    String filterPattern = (String) env.get(RmiConnector.JMX_REMOTE_RMI_SERVER_SERIAL_FILTER_PATTERN);
    // Legitimate JMX management argument types must pass.
    assertEquals(readWithFilter("an attribute name", filterPattern), "an attribute name");
    assertEquals(readWithFilter(new String[] { "a", "b" }, filterPattern), new String[] { "a", "b" });
    assertEquals(readWithFilter(new ObjectName("org.opends.server:type=test"), filterPattern),
        new ObjectName("org.opends.server:type=test"));
    assertEquals(readWithFilter(new Attribute("ds-cfg-listen-port", 1689), filterPattern),
        new Attribute("ds-cfg-listen-port", 1689));
    // Arbitrary application types must be rejected before readObject() runs.
    assertRejectedByFilter(new UnexpectedArgument(), filterPattern);
  }
  /** An arbitrary serializable type standing in for a gadget argument. */
  private static final class UnexpectedArgument implements java.io.Serializable
  {
    private static final long serialVersionUID = 1L;
  }
  /** Verifies the configured filter allows only the expected credential payload. */
  @Test
  public void serialFilterAllowsOnlyTwoElementStringArray() throws Exception
opendj-server-legacy/src/test/java/org/opends/server/types/PrivilegeTestCase.java
@@ -218,7 +218,31 @@
      "givenName: PWReset",
      "sn: Target",
      "uid: pwreset.target",
      "userPassword: password");
      "userPassword: password",
      "",
      "dn: cn=Proxy Only User,o=test",
      "objectClass: top",
      "objectClass: person",
      "objectClass: organizationalPerson",
      "objectClass: inetOrgPerson",
      "cn: Proxy Only User",
      "givenName: Proxy",
      "sn: User",
      "uid: proxy.only",
      "userPassword: password",
      "ds-privilege-name: proxied-auth",
      "",
      "dn: cn=Proxy ACI User,o=test",
      "objectClass: top",
      "objectClass: person",
      "objectClass: organizationalPerson",
      "objectClass: inetOrgPerson",
      "cn: Proxy ACI User",
      "givenName: Proxy",
      "sn: ACI User",
      "uid: proxy.aci",
      "userPassword: password",
      "ds-privilege-name: proxied-auth");
    TestCaseUtils.applyModifications(false,
      "dn: o=test",
@@ -230,6 +254,8 @@
           "userdn=\"ldap:///cn=Unprivileged Root,cn=Root DNs,cn=config\";)",
      "aci: (version 3.0; acl \"Privileged User\"; allow (proxy) " +
           "userdn=\"ldap:///cn=Privileged User,o=test\";)",
      "aci: (version 3.0; acl \"Proxy ACI User\"; allow (proxy) " +
           "userdn=\"ldap:///cn=Proxy ACI User,o=test\";)",
      "aci: (targetattr=\"*\")(version 3.0; acl \"PWReset Target\"; " +
           "allow (all) userdn=\"ldap:///cn=PWReset Target,o=test\";)");
@@ -2146,6 +2172,146 @@
  /**
   * Regression test for GHSA-p279-2cqp-84jg: an authentication identity that
   * holds the PROXIED_AUTH privilege but is not granted the "proxy" access
   * control right for the target must be denied when specifying an alternate
   * authorization ID through SASL PLAIN using the "dn:" syntax, matching the
   * behaviour of the proxied authorization control and DIGEST-MD5.
   *
   * @throws  Exception  If an unexpected problem occurs.
   */
  @Test
  public void testPLAINDifferentAuthzIDNoProxyAciDeniedDNColon()
         throws Exception
  {
    // Control: the proxy user can authenticate and act as itself, proving its
    // entry and password are valid so the denial below is an authorization
    // failure and not a setup or authentication problem.
    String[] selfArgs =
    {
      "--noPropertiesFile",
      "-h", "127.0.0.1",
      "-p", String.valueOf(TestCaseUtils.getServerLdapPort()),
      "-o", "mech=PLAIN",
      "-o", "authid=dn:cn=Proxy Only User,o=test",
      "-o", "authzid=dn:cn=Proxy Only User,o=test",
      "-w", "password",
      "-b", "",
      "-s", "base",
      "(objectClass=*)"
    };
    assertEquals(runSearchWithSystemErr(selfArgs), 0);
    // Holding PROXIED_AUTH but lacking a "proxy" ACI over the target must be
    // denied with INVALID_CREDENTIALS, matching DIGEST-MD5 / GSSAPI.
    String[] args =
    {
      "--noPropertiesFile",
      "-h", "127.0.0.1",
      "-p", String.valueOf(TestCaseUtils.getServerLdapPort()),
      "-o", "mech=PLAIN",
      "-o", "authid=dn:cn=Proxy Only User,o=test",
      "-o", "authzid=dn:cn=Unprivileged User,o=test",
      "-w", "password",
      "-b", "o=test",
      "-s", "base",
      "(objectClass=*)"
    };
    assertEquals(runSearch(args), INVALID_CREDENTIALS.intValue());
  }
  /**
   * Regression test for GHSA-p279-2cqp-84jg: an authentication identity that
   * holds the PROXIED_AUTH privilege but is not granted the "proxy" access
   * control right for the target must be denied when specifying an alternate
   * authorization ID through SASL PLAIN using the "u:" syntax, matching the
   * behaviour of the proxied authorization control and DIGEST-MD5.
   *
   * @throws  Exception  If an unexpected problem occurs.
   */
  @Test
  public void testPLAINDifferentAuthzIDNoProxyAciDeniedUColon()
         throws Exception
  {
    // Control: the proxy user can authenticate and act as itself, proving its
    // entry and password are valid so the denial below is an authorization
    // failure and not a setup or authentication problem.
    String[] selfArgs =
    {
      "--noPropertiesFile",
      "-h", "127.0.0.1",
      "-p", String.valueOf(TestCaseUtils.getServerLdapPort()),
      "-o", "mech=PLAIN",
      "-o", "authid=u:proxy.only",
      "-o", "authzid=u:proxy.only",
      "-w", "password",
      "-b", "",
      "-s", "base",
      "(objectClass=*)"
    };
    assertEquals(runSearchWithSystemErr(selfArgs), 0);
    // Holding PROXIED_AUTH but lacking a "proxy" ACI over the target must be
    // denied with INVALID_CREDENTIALS, matching DIGEST-MD5 / GSSAPI.
    String[] args =
    {
      "--noPropertiesFile",
      "-h", "127.0.0.1",
      "-p", String.valueOf(TestCaseUtils.getServerLdapPort()),
      "-o", "mech=PLAIN",
      "-o", "authid=u:proxy.only",
      "-o", "authzid=u:unprivileged.user",
      "-w", "password",
      "-b", "o=test",
      "-s", "base",
      "(objectClass=*)"
    };
    assertEquals(runSearch(args), INVALID_CREDENTIALS.intValue());
  }
  /**
   * Regression test for GHSA-p279-2cqp-84jg: an authentication identity that
   * holds the PROXIED_AUTH privilege AND is granted the "proxy" access control
   * right for the target through an ACI, but does NOT hold the bypass-acl
   * privilege, must be allowed to assume an alternate authorization ID through
   * SASL PLAIN. This confirms the added mayProxy check does not over-deny
   * legitimate ACI-granted proxying (the bypass-acl proxy users exercised by
   * the other success tests short-circuit the ACI evaluation).
   *
   * @throws  Exception  If an unexpected problem occurs.
   */
  @Test
  public void testPLAINDifferentAuthzIDWithProxyAciSucceedsDNColon()
         throws Exception
  {
    String[] args =
    {
      "--noPropertiesFile",
      "-h", "127.0.0.1",
      "-p", String.valueOf(TestCaseUtils.getServerLdapPort()),
      "-o", "mech=PLAIN",
      "-o", "authid=dn:cn=Proxy ACI User,o=test",
      "-o", "authzid=dn:cn=Unprivileged User,o=test",
      "-w", "password",
      "-b", "o=test",
      "-s", "base",
      "(objectClass=*)"
    };
    assertEquals(runSearchWithSystemErr(args), 0);
  }
  /**
   * Tests the ability to disable a privilege so that an operation which will
   * fail for a user without an appropriate privilege will succeed if that
   * privilege is disabled.
opendj-server-legacy/src/test/java/org/opends/server/types/RawFilterDecodeStackOverflowTestCase.java
New file
@@ -0,0 +1,212 @@
/*
 * 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.server.types;
import java.util.ArrayList;
import java.util.List;
import org.forgerock.opendj.io.ASN1;
import org.forgerock.opendj.io.ASN1Reader;
import org.forgerock.opendj.io.ASN1Writer;
import org.forgerock.opendj.ldap.ByteStringBuilder;
import org.opends.server.DirectoryServerTestCase;
import org.opends.server.protocols.ldap.LDAPFilter;
import org.testng.annotations.Test;
import static org.opends.server.protocols.ldap.LDAPConstants.TYPE_FILTER_AND;
import static org.opends.server.protocols.ldap.LDAPResultCode.PROTOCOL_ERROR;
import static org.opends.server.util.ServerConstants.MAX_NESTED_FILTER_DEPTH;
import static org.testng.Assert.*;
/**
 * Regression test for GHSA-rv4q-c6mr-wxp7 (OPENDJ-001):
 * <em>Unauthenticated LDAP search-filter decode stack exhaustion</em>.
 *
 * <p>Before the fix, the BER decoder {@link RawFilter#decode(ASN1Reader)} —&gt;
 * {@code decodeCompoundFilter} (AND/OR) / {@code decodeNotFilter} (NOT) recursed
 * once per nesting level with <strong>no depth bound</strong>. The
 * {@code MAX_NESTED_FILTER_DEPTH} guard only protected the filter
 * <em>evaluation</em> path, which runs strictly <em>after</em> decode. An
 * anonymous client could therefore send a single small {@code SearchRequest}
 * whose filter was nested tens of thousands of levels deep; decoding it
 * overflowed the JVM stack with a {@link StackOverflowError} <strong>before</strong>
 * the evaluation guard was ever reached. Because {@code StackOverflowError} is a
 * {@link java.lang.Error} (not an {@link Exception}), it escaped the
 * {@code catch (Exception)} blocks and killed the shared request-handler thread
 * &rarr; listener-wide DoS.
 *
 * <p>After the fix, {@link RawFilter#decode(ASN1Reader)} threads a depth counter
 * and rejects over-nested filters with a {@link LDAPException}
 * ({@code PROTOCOL_ERROR}) instead of recursing into a stack overflow.
 *
 * <p>This test builds the exact malicious wire encoding (a definite-length BER
 * tree of nested {@code 0xA0} AND TLVs wrapping an {@code (objectClass=*)}
 * present leaf) and decodes it on a thread with a small stack, so that — were the
 * bound ever removed — the overflow would still be deterministic and fast on any
 * platform.
 */
@SuppressWarnings("javadoc")
public class RawFilterDecodeStackOverflowTestCase extends DirectoryServerTestCase
{
  /** Nesting depth that would overflow even an oversized default stack. */
  private static final int OVERFLOW_DEPTH = 100_000;
  /** A small, deterministic stack so any unbounded recursion overflows quickly. */
  private static final long DECODE_STACK_BYTES = 256L * 1024L;
  /**
   * A filter nested well within {@link #MAX_NESTED_FILTER_DEPTH} decodes
   * normally. This is the control that proves the harness and the wire encoding
   * are correct, so that the rejection in
   * {@link #deeplyNestedFilterIsRejectedWithoutStackOverflow()} is attributable
   * to the depth guard and not to a malformed payload.
   */
  @Test
  public void shallowNestedFilterDecodesWithoutError() throws Exception
  {
    byte[] payload = buildNestedAndFilter(MAX_NESTED_FILTER_DEPTH / 2);
    Throwable result = decodeOnBoundedStack(payload);
    assertNull(result, "A filter nested " + (MAX_NESTED_FILTER_DEPTH / 2)
        + " levels deep must decode cleanly, but threw: " + result);
  }
  /**
   * Reproduction-turned-regression check: a deeply nested AND filter must be
   * rejected with a controlled {@link LDAPException} ({@code PROTOCOL_ERROR})
   * rather than overflowing the stack with a {@link StackOverflowError}.
   *
   * <p>On the vulnerable (pre-fix) code this fails because decode recurses until
   * the stack overflows and the resulting {@code java.lang.Error} escapes. With
   * the depth guard in place it passes.
   */
  @Test
  public void deeplyNestedFilterIsRejectedWithoutStackOverflow() throws Exception
  {
    byte[] payload = buildNestedAndFilter(OVERFLOW_DEPTH);
    // Well under the ~5 MB ds-cfg-max-request-size cap: depth costs only a few
    // bytes per level, so the request-size limit never bounds the depth.
    assertTrue(payload.length < 5 * 1024 * 1024,
        "PoC payload (" + payload.length + " bytes) should be far below the 5 MB request cap");
    Throwable result = decodeOnBoundedStack(payload);
    assertNotNull(result,
        "Decoding a " + OVERFLOW_DEPTH + "-level filter must be rejected, but it succeeded");
    assertFalse(result instanceof StackOverflowError,
        "VULNERABLE: decode overflowed the stack instead of rejecting the over-nested filter: " + result);
    assertTrue(result instanceof LDAPException,
        "Expected a controlled LDAPException, but got: " + result);
    assertEquals(((LDAPException) result).getResultCode(), PROTOCOL_ERROR,
        "Over-nested filter should be rejected as a protocol error");
  }
  /**
   * Decodes {@code payload} via {@link RawFilter#decode(ASN1Reader)} on a worker
   * thread with a small stack and returns the {@link Throwable} that escaped
   * decode, or {@code null} if decode completed normally. Captures
   * {@link Throwable} (including {@link Error}) so a regression to the unbounded
   * recursion surfaces as a {@link StackOverflowError} rather than killing the
   * test JVM thread silently.
   */
  private Throwable decodeOnBoundedStack(final byte[] payload) throws InterruptedException
  {
    final Throwable[] escaped = new Throwable[1];
    Runnable decode = new Runnable()
    {
      @Override
      public void run()
      {
        try
        {
          ASN1Reader reader = ASN1.getReader(payload);
          RawFilter.decode(reader);
        }
        catch (Throwable t)
        {
          escaped[0] = t;
        }
      }
    };
    Thread worker = new Thread(null, decode, "ghsa-rv4q-decode", DECODE_STACK_BYTES);
    worker.start();
    worker.join();
    return escaped[0];
  }
  /**
   * Builds the BER (definite-length) wire encoding of an AND filter nested
   * {@code depth} levels deep, wrapping an {@code (objectClass=*)} present leaf:
   * <pre>
   *   A0 L ( A0 L ( ... ( 87 0B "objectClass" ) ... ) )
   * </pre>
   * Constructed inside-out so the length fields are exact, which is what
   * {@link RawFilter#decode} reads. (Indefinite-length BER is not used because
   * the OpenDJ reader does not support it.)
   */
  private static byte[] buildNestedAndFilter(final int depth) throws Exception
  {
    // Innermost leaf: presence filter (objectClass=*).
    ByteStringBuilder leafBuilder = new ByteStringBuilder();
    ASN1Writer writer = ASN1.getWriter(leafBuilder);
    LDAPFilter.createPresenceFilter("objectClass").write(writer);
    writer.flush();
    byte[] leaf = leafBuilder.toByteString().toByteArray();
    // Build each AND wrapper prefix (0xA0 + definite length) from the inside out.
    List<byte[]> prefixes = new ArrayList<>(depth);
    int contentLen = leaf.length;
    for (int i = 0; i < depth; i++)
    {
      byte[] lengthBytes = berLength(contentLen);
      byte[] prefix = new byte[1 + lengthBytes.length];
      prefix[0] = TYPE_FILTER_AND; // 0xA0
      System.arraycopy(lengthBytes, 0, prefix, 1, lengthBytes.length);
      prefixes.add(prefix);
      contentLen += prefix.length;
    }
    // Assemble: outermost prefix first ... innermost prefix ... leaf.
    byte[] out = new byte[contentLen];
    int pos = 0;
    for (int i = prefixes.size() - 1; i >= 0; i--)
    {
      byte[] prefix = prefixes.get(i);
      System.arraycopy(prefix, 0, out, pos, prefix.length);
      pos += prefix.length;
    }
    System.arraycopy(leaf, 0, out, pos, leaf.length);
    return out;
  }
  /** Encodes {@code len} as a BER definite-length field (short or long form). */
  private static byte[] berLength(final int len)
  {
    if (len < 0x80)
    {
      return new byte[] { (byte) len };
    }
    int numBytes = len <= 0xFF ? 1 : len <= 0xFFFF ? 2 : len <= 0xFFFFFF ? 3 : 4;
    byte[] out = new byte[1 + numBytes];
    out[0] = (byte) (0x80 | numBytes);
    for (int i = 0; i < numBytes; i++)
    {
      out[numBytes - i] = (byte) (len >>> (8 * i));
    }
    return out;
  }
}
opendj-server-msad-plugin/pom.xml
@@ -5,7 +5,7 @@
    <parent>
        <groupId>org.openidentityplatform.opendj</groupId>
        <artifactId>opendj-parent</artifactId>
        <version>5.1.2-SNAPSHOT</version>
        <version>5.2.0-SNAPSHOT</version>
    </parent>
    <artifactId>opendj-server-msad-plugin</artifactId>
opendj-server/pom.xml
@@ -20,7 +20,7 @@
  <parent>
    <groupId>org.openidentityplatform.opendj</groupId>
    <artifactId>opendj-parent</artifactId>
    <version>5.1.2-SNAPSHOT</version>
    <version>5.2.0-SNAPSHOT</version>
  </parent>
  <artifactId>opendj-server</artifactId>
  <name>OpenDJ Server NG</name>
pom.xml
@@ -19,7 +19,7 @@
    <modelVersion>4.0.0</modelVersion>
    <groupId>org.openidentityplatform.opendj</groupId>
    <artifactId>opendj-parent</artifactId>
    <version>5.1.2-SNAPSHOT</version>
    <version>5.2.0-SNAPSHOT</version>
    <packaging>pom</packaging>
    <name>OpenDJ Directory Services Project</name>
@@ -35,7 +35,7 @@
        <product.locales>ca_ES,es,de,fr,ja,ko,pl,zh_CN,zh_TW</product.locales>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <localized.jars.classifier>i18n</localized.jars.classifier>
        <commons.version>3.1.2-SNAPSHOT</commons.version>
        <commons.version>3.1.2</commons.version>
        <freemarker.version>2.3.34</freemarker.version>
        <metrics-core.version>4.2.30</metrics-core.version>
        <bc.fips.version>2.1.2</bc.fips.version>