From de42bdce8ff329998ad4eef467503ea3a4fafd89 Mon Sep 17 00:00:00 2001
From: Valera V Harseko <vharseko@3a-systems.ru>
Date: Wed, 21 Oct 2026 08:54:06 +0000
Subject: [PATCH] GHSA-6f6q-q9gw-4jr4 Sort and VLV controls let authenticated users learn password hashes and other attributes hidden by access control

---
 opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/VLVIndex.java                    |   45 +
 opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/VLVSortConcurrentChangeTest.java |  706 ++++++++++++++++++++++
 opendj-server-legacy/src/test/java/org/opends/server/controls/VLVSortAccessControlTest.java              |  777 ++++++++++++++++++++++++
 opendj-server-legacy/src/main/java/org/opends/server/core/AccessControlConfigManager.java                |    3 
 opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/EntryContainer.java              |  267 ++++++-
 opendj-server-legacy/src/main/java/org/opends/server/api/AccessControlHandler.java                       |   15 
 opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciContainer.java          |   18 
 opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciHandler.java            |    8 
 opendj-server-legacy/src/test/java/org/opends/server/core/AccessControlConfigManagerTest.java            |   42 +
 9 files changed, 1,811 insertions(+), 70 deletions(-)

diff --git a/opendj-server-legacy/src/main/java/org/opends/server/api/AccessControlHandler.java b/opendj-server-legacy/src/main/java/org/opends/server/api/AccessControlHandler.java
index a3afb39..fd8a60d 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/api/AccessControlHandler.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/api/AccessControlHandler.java
@@ -13,6 +13,7 @@
  *
  * Copyright 2006-2009 Sun Microsystems, Inc.
  * Portions Copyright 2011-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.opends.server.api;
 
@@ -347,6 +348,10 @@
    * Indicates whether the provided operation search filter is allowed
    * based on the access control configuration. This method should not
    * alter the provided operation in any way.
+   * <p>
+   * A sorted search also calls it, with a presence filter on an attribute
+   * type, to decide whether the values of that type may take part in the
+   * sort key of an entry, which it may never return.
    *
    * @param operation
    *          The operation for which to make the determination.
@@ -371,6 +376,10 @@
    * Indicates whether the provided search result entry may be sent to
    * the client. Implementations <b>must not under any
    * circumstances</b> modify the search entry in any way.
+   * <p>
+   * A sorted search also calls it for every entry it sorts, to leave out
+   * of the sort the entries the client may not see, including entries it
+   * never returns.
    *
    * @param operation
    *          The operation currently being processed (this will
@@ -391,6 +400,12 @@
    * Filter the contents of the provided entry such that it no longer
    * contains any attributes or values that the client is not
    * permitted to access.
+   * <p>
+   * The unfiltered entry is expected to be the very object for which
+   * {@link #maySend(Operation, SearchResultEntry)} has just returned
+   * {@code true}. An implementation may reuse what it decided there
+   * for that object only: any other object, such as a copy of the
+   * entry or the entry read again, is checked without it.
    *
    * @param operation
    *          The operation currently being processed (this will
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciContainer.java b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciContainer.java
index f3d0ddd..2f10a3e 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciContainer.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciContainer.java
@@ -13,6 +13,7 @@
  *
  * Copyright 2008 Sun Microsystems, Inc.
  * Portions Copyright 2011-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.opends.server.authorization.dseecompat;
 
@@ -215,19 +216,18 @@
         }
 
         //If an ACI evaluated because of an Targetattr="*", then the
-        //AciHandler.maySend method signaled this via adding this attachment
-        //string.
-        String allUserAttrs=
-                  (String)operation.getAttachment(ALL_USER_ATTRS_MATCHED);
-        if(allUserAttrs != null)
+        //AciHandler.maySend method signaled this via adding this attachment,
+        //holding the entry it evaluated: it only holds for that very entry.
+        if (entry != null
+            && operation.getAttachment(ALL_USER_ATTRS_MATCHED) == entry)
         {
           evalAllAttributes |= ACI_USER_ATTR_STAR_MATCHED;
         }
         //If an ACI evaluated because of an Targetattr="+", then the
-        //AciHandler.maySend method signaled this via adding this attachment
-        //string.
-        String allOpAttrs=(String)operation.getAttachment(ALL_OP_ATTRS_MATCHED);
-        if(allOpAttrs != null)
+        //AciHandler.maySend method signaled this via adding this attachment,
+        //holding the entry it evaluated: it only holds for that very entry.
+        if (entry != null
+            && operation.getAttachment(ALL_OP_ATTRS_MATCHED) == entry)
         {
           evalAllAttributes |= ACI_OP_ATTR_PLUS_MATCHED;
         }
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciHandler.java b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciHandler.java
index 010928b..38d4de7 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciHandler.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciHandler.java
@@ -80,12 +80,14 @@
   /**
    * String used to indicate that the evaluating ACI had a all
    * operational attributes targetattr match (targetattr="+").
+   * The attachment's value is the entry it holds for.
    */
   static final String ALL_OP_ATTRS_MATCHED = "allOpAttrsMatched";
 
   /**
    * String used to indicate that the evaluating ACI had a all user
    * attributes targetattr match (targetattr="*").
+   * The attachment's value is the entry it holds for.
    */
   static final String ALL_USER_ATTRS_MATCHED = "allUserAttrsMatched";
 
@@ -456,13 +458,15 @@
       return false;
     }
 
+    // Each shortcut holds the very entry it was decided for, so that filterEntry() applies it to that entry alone
+    // and to no other entry of the operation (GHSA-6f6q-q9gw-4jr4).
     if (!container.hasEvalUserAttributes())
     {
-      operation.setAttachment(ALL_USER_ATTRS_MATCHED, ALL_USER_ATTRS_MATCHED);
+      operation.setAttachment(ALL_USER_ATTRS_MATCHED, entry);
     }
     if (!container.hasEvalOpAttributes())
     {
-      operation.setAttachment(ALL_OP_ATTRS_MATCHED, ALL_OP_ATTRS_MATCHED);
+      operation.setAttachment(ALL_OP_ATTRS_MATCHED, entry);
     }
 
     return true;
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/EntryContainer.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/EntryContainer.java
index 5825abe..5e2c01a 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/EntryContainer.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/EntryContainer.java
@@ -33,10 +33,12 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.NoSuchElementException;
 import java.util.Objects;
+import java.util.Set;
 import java.util.TreeMap;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
@@ -51,6 +53,7 @@
 import org.forgerock.opendj.config.server.ConfigurationAddListener;
 import org.forgerock.opendj.config.server.ConfigurationChangeListener;
 import org.forgerock.opendj.config.server.ConfigurationDeleteListener;
+import org.forgerock.opendj.ldap.AttributeDescription;
 import org.forgerock.opendj.ldap.ByteSequence;
 import org.forgerock.opendj.ldap.ByteString;
 import org.forgerock.opendj.ldap.ByteStringBuilder;
@@ -64,6 +67,7 @@
 import org.forgerock.opendj.server.config.server.PluggableBackendCfg;
 import org.forgerock.util.Pair;
 import org.opends.messages.CoreMessages;
+import org.opends.server.api.AccessControlHandler;
 import org.opends.server.api.ClientConnection;
 import org.opends.server.api.EntryCache;
 import org.opends.server.api.VirtualAttributeProvider;
@@ -85,6 +89,7 @@
 import org.opends.server.controls.SubtreeDeleteControl;
 import org.opends.server.controls.VLVRequestControl;
 import org.opends.server.controls.VLVResponseControl;
+import org.opends.server.core.AccessControlConfigManager;
 import org.opends.server.core.AddOperation;
 import org.opends.server.core.DeleteOperation;
 import org.opends.server.core.DirectoryServer;
@@ -103,6 +108,7 @@
 import org.opends.server.types.Operation;
 import org.opends.server.types.Privilege;
 import org.opends.server.types.SearchFilter;
+import org.opends.server.types.SearchResultEntry;
 import org.opends.server.types.VirtualAttributeRule;
 import org.opends.server.util.ServerConstants;
 import org.opends.server.util.StaticUtils;
@@ -955,16 +961,45 @@
 
           EntryIDSet candidateEntryIDs = null;
           boolean candidatesAreInScope = false;
+          final AccessControlHandler<?> aciHandler = sortRequest != null ? sortAccessControl(searchOperation) : null;
+          boolean sortedByVLVIndex = false;
           if (sortRequest != null)
           {
             for (VLVIndex vlvIndex : vlvIndexMap.values())
             {
               try
               {
-                candidateEntryIDs = vlvIndex.evaluate(txn, searchOperation, sortRequest, vlvRequest, debugBuffer);
+                if (aciHandler == null)
+                {
+                  candidateEntryIDs = vlvIndex.evaluate(txn, searchOperation, sortRequest, vlvRequest, debugBuffer);
+                  sortedByVLVIndex = candidateEntryIDs != null;
+                  if (sortedByVLVIndex)
+                  {
+                    addServerSideSortControl(searchOperation, SUCCESS);
+                  }
+                }
+                else if (vlvIndex.isApplicable(searchOperation, sortRequest))
+                {
+                  // A VLV index answers from its stored keys alone, so it cannot tell which entries and sort
+                  // values the caller may read. For a caller subject to access control, only take the entries of
+                  // the index from it, and let the sort below order them through the caller's access controls
+                  // (GHSA-6f6q-q9gw-4jr4).
+                  if (refuseOverEntryLimit(vlvIndex.getEntryCount(txn), searchOperation, vlvRequest))
+                  {
+                    return null;
+                  }
+                  candidateEntryIDs = getEntriesToSort(txn, vlvIndex, searchOperation, sortRequest, debugBuffer);
+                  // The index may have grown since it was counted: the sort must not read more entries than may be
+                  // examined. An index grown over the most entries read from it at once gives an undefined set, which
+                  // no limit admits.
+                  if (candidateEntryIDs != null
+                      && refuseOverEntryLimit(candidateEntryIDs.size(), searchOperation, vlvRequest))
+                  {
+                    return null;
+                  }
+                }
                 if (candidateEntryIDs != null)
                 {
-                  addServerSideSortControl(searchOperation, SUCCESS);
                   candidatesAreInScope = true;
                   break;
                 }
@@ -1009,53 +1044,50 @@
                 candidatesAreInScope = true;
               }
             }
+          }
 
-            if (sortRequest != null)
-            {
-              // If the sort key is not present, the sorting will generate the
-              // default ordering. VLV search request goes through as if
-              // this sort key was not found in the user entry.
-              try
-              {
-                List<SortKey> sortKeys = sortRequest.getSortKeys();
-                reorderedCandidateEntryIDs = sort(txn, candidateEntryIDs, searchOperation, sortKeys, vlvRequest);
-              }
-              catch (DirectoryException de)
-              {
-                reorderedCandidateEntryIDs = candidateEntryIDs.toLongArray();
-                serverSideSortControlError(searchOperation, sortRequest, de);
-              }
-              try
-              {
-                if (sortRequest.containsSortKeys())
-                {
-                  addServerSideSortControl(searchOperation, SUCCESS);
-                }
-                else
-                {
-                  /*
-                   * There is no sort key associated with the sort control.
-                   * Since it came here it means that the criticality is false
-                   * so let the server return all search results unsorted and
-                   * include the sortKeyResponseControl in the searchResultDone
-                   * message.
-                   */
-                  addServerSideSortControl(searchOperation, NO_SUCH_ATTRIBUTE);
-                }
-              }
-              catch (DirectoryException de)
-              {
-                serverSideSortControlError(searchOperation, sortRequest, de);
-              }
-            }
-            else
-            {
-              reorderedCandidateEntryIDs = candidateEntryIDs.toLongArray();
-            }
+          if (sortedByVLVIndex || sortRequest == null)
+          {
+            reorderedCandidateEntryIDs = candidateEntryIDs.toLongArray();
           }
           else
           {
-            reorderedCandidateEntryIDs = candidateEntryIDs.toLongArray();
+            // If the sort key is not present, the sorting will generate the
+            // default ordering. VLV search request goes through as if
+            // this sort key was not found in the user entry.
+            try
+            {
+              List<SortKey> sortKeys = sortRequest.getSortKeys();
+              reorderedCandidateEntryIDs =
+                  sort(txn, candidateEntryIDs, searchOperation, sortKeys, vlvRequest, aciHandler);
+            }
+            catch (DirectoryException de)
+            {
+              reorderedCandidateEntryIDs = candidateEntryIDs.toLongArray();
+              serverSideSortControlError(searchOperation, sortRequest, de);
+            }
+            try
+            {
+              if (sortRequest.containsSortKeys())
+              {
+                addServerSideSortControl(searchOperation, SUCCESS);
+              }
+              else
+              {
+                /*
+                 * There is no sort key associated with the sort control.
+                 * Since it came here it means that the criticality is false
+                 * so let the server return all search results unsorted and
+                 * include the sortKeyResponseControl in the searchResultDone
+                 * message.
+                 */
+                addServerSideSortControl(searchOperation, NO_SUCH_ATTRIBUTE);
+              }
+            }
+            catch (DirectoryException de)
+            {
+              serverSideSortControlError(searchOperation, sortRequest, de);
+            }
           }
 
           // If requested, construct and return a fictitious entry containing
@@ -1134,6 +1166,64 @@
           addPagedResultsControl(searchOperation, pageRequest, null);
         }
 
+        /**
+         * Refuses the search, and returns {@code true}, if the entries of a VLV index, as counted or as read, are more
+         * than may be examined for a caller subject to access control: sorting them through its access controls reads
+         * every one of them.
+         */
+        private boolean refuseOverEntryLimit(long entryCount, SearchOperation searchOperation,
+            VLVRequestControl vlvRequest)
+        {
+          final int lookthroughLimit = searchOperation.getClientConnection().getLookthroughLimit();
+          final int entryLimit =
+              lookthroughLimit > 0 ? Math.min(lookthroughLimit, CURSOR_ENTRY_LIMIT) : CURSOR_ENTRY_LIMIT;
+          if (entryCount <= entryLimit)
+          {
+            return false;
+          }
+          searchOperation.setResultCode(ResultCode.ADMIN_LIMIT_EXCEEDED);
+          searchOperation.appendErrorMessage(NOTE_LOOKTHROUGH_LIMIT_EXCEEDED.get(entryLimit));
+          addServerSideSortControl(searchOperation, ADMIN_LIMIT_EXCEEDED);
+          if (vlvRequest != null)
+          {
+            addVLVResponseControl(searchOperation, 0, 0, ADMIN_LIMIT_EXCEEDED);
+          }
+          return true;
+        }
+
+        /**
+         * Returns the entries of a VLV index, in entry ID order, for the sort of a caller subject to access control.
+         * An index grown over the most entries read from it at once since it was checked gives an undefined set,
+         * which the caller refuses as it refuses an index already counting that many. An index that gives no entries
+         * otherwise, having stopped being applicable or no longer holding the entries it counts, gives {@code null}:
+         * the search then goes on as without it.
+         */
+        private EntryIDSet getEntriesToSort(ReadableTransaction txn, VLVIndex vlvIndex,
+            SearchOperation searchOperation, ServerSideSortRequestControl sortRequest, StringBuilder debugBuffer)
+            throws DirectoryException
+        {
+          // Without a VLV request, the index gives all its entries, but nothing when it holds none.
+          final EntryIDSet entryIDs = vlvIndex.evaluate(txn, searchOperation, sortRequest, null, debugBuffer);
+          if (entryIDs == null)
+          {
+            if (!vlvIndex.isApplicable(searchOperation, sortRequest))
+            {
+              return null;
+            }
+            final int entryCount = vlvIndex.getEntryCount(txn);
+            if (entryCount == 0)
+            {
+              return EntryIDSet.newDefinedSet();
+            }
+            return entryCount > CURSOR_ENTRY_LIMIT ? EntryIDSet.newUndefinedSet() : null;
+          }
+          // They come in the order of the stored keys, which the caller may not read: no road below, the fallback
+          // after a sort error included, may hand them out in that order.
+          final long[] ids = entryIDs.toLongArray();
+          Arrays.sort(ids);
+          return EntryIDSet.newDefinedSet(ids);
+        }
+
         private void serverSideSortControlError(final SearchOperation searchOperation,
             ServerSideSortRequestControl sortRequest, DirectoryException de) throws DirectoryException
         {
@@ -2788,8 +2878,17 @@
     return baseEntry;
   }
 
+  /**
+   * Sorts the candidate entries of a search, then applies its VLV request if any.
+   * <p>
+   * Unless {@code aciHandler} is {@code null}, the sort goes through the caller's access controls: an entry the
+   * caller may not see takes no part in it, and a sort attribute the caller may not read on an entry sorts as if
+   * the entry had no value for it, so that neither the order nor the VLV target position or content count disclose
+   * them (GHSA-6f6q-q9gw-4jr4).
+   */
   private long[] sort(ReadableTransaction txn, EntryIDSet entryIDSet, SearchOperation searchOperation,
-      List<SortKey> sortKeys, VLVRequestControl vlvRequest) throws DirectoryException
+      List<SortKey> sortKeys, VLVRequestControl vlvRequest, AccessControlHandler<?> aciHandler)
+      throws DirectoryException
   {
     if (!entryIDSet.isDefined())
     {
@@ -2806,9 +2905,14 @@
       try
       {
         Entry e = getEntry(txn, id);
-        if (e.matchesBaseAndScope(baseDN, scope) && filter.matchesEntry(e))
+        // The entry may have been deleted since the candidates were read, as searchIndexed() also expects.
+        if (e != null && e.matchesBaseAndScope(baseDN, scope) && filter.matchesEntry(e))
         {
-          sortMap.put(encodeVLVKey(sortKeys, e, id.longValue()), id);
+          final ByteString vlvKey = encodeVisibleVLVKey(searchOperation, aciHandler, sortKeys, e, id.longValue());
+          if (vlvKey != null)
+          {
+            sortMap.put(vlvKey, id);
+          }
         }
       }
       catch (Exception e)
@@ -2832,6 +2936,73 @@
     return sortByGreaterThanOrEqualAssertion(searchOperation, vlvRequest, sortKeys, sortMap);
   }
 
+  /**
+   * Returns the access controls the sort of a search must go through, or {@code null} if they do not apply to it:
+   * access control is disabled, or the caller holds the bypass-acl privilege, as internal operations do.
+   */
+  private static AccessControlHandler<?> sortAccessControl(SearchOperation searchOperation)
+  {
+    final AccessControlConfigManager accessControl = AccessControlConfigManager.getInstance();
+    if (!accessControl.isAccessControlEnabled()
+        || searchOperation.getClientConnection().hasPrivilege(Privilege.BYPASS_ACL, searchOperation))
+    {
+      return null;
+    }
+    return accessControl.getAccessControlHandler();
+  }
+
+  /**
+   * Returns the VLV key of an entry as the caller may see it, or {@code null} if the caller may not see the entry.
+   * <p>
+   * Without access controls, the key is that of the entry. Otherwise the entry must pass the check a returned entry
+   * passes, and the values of the attribute types the caller may not read on it are left out of the key. The entry
+   * is the one read from the backend, with its virtual attributes, as a returned entry is.
+   */
+  private static ByteString encodeVisibleVLVKey(SearchOperation searchOperation, AccessControlHandler<?> aciHandler,
+      List<SortKey> sortKeys, Entry entry, long entryID)
+  {
+    if (aciHandler == null)
+    {
+      return encodeVLVKey(sortKeys, entry, entryID);
+    }
+    if (!aciHandler.maySend(searchOperation, new SearchResultEntry(entry)))
+    {
+      return null;
+    }
+    // A sort key takes its values from its attribute type and from the subtypes of it on the entry, and access
+    // control applies to each of these types on its own.
+    final Set<AttributeType> checkedTypes = new HashSet<>();
+    final Set<AttributeType> unreadableTypes = new HashSet<>();
+    for (SortKey sortKey : sortKeys)
+    {
+      final AttributeType sortType = AttributeDescription.valueOf(sortKey.getAttributeDescription()).getAttributeType();
+      for (Attribute attribute : entry.getAllAttributes(sortType))
+      {
+        final AttributeType type = attribute.getAttributeDescription().getAttributeType();
+        if (checkedTypes.add(type) && !mayRead(searchOperation, aciHandler, entry, type))
+        {
+          unreadableTypes.add(type);
+        }
+      }
+    }
+    return encodeVLVKey(sortKeys, entry, entryID, unreadableTypes);
+  }
+
+  /** Indicates whether the caller may read an attribute type on an entry, checked as the assertion control does. */
+  private static boolean mayRead(SearchOperation searchOperation, AccessControlHandler<?> aciHandler, Entry entry,
+      AttributeType attributeType)
+  {
+    try
+    {
+      return aciHandler.isAllowed(searchOperation, entry, SearchFilter.createPresenceFilter(attributeType));
+    }
+    catch (DirectoryException e)
+    {
+      logger.traceException(e);
+      return false;
+    }
+  }
+
   private static final long[] toArray(Collection<EntryID> entryIDs)
   {
     final long[] array = new long[entryIDs.size()];
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/VLVIndex.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/VLVIndex.java
index 223e4c0..dc2bd46 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/VLVIndex.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/VLVIndex.java
@@ -31,6 +31,7 @@
 import java.util.Iterator;
 import java.util.LinkedList;
 import java.util.List;
+import java.util.Set;
 import java.util.TreeSet;
 
 import org.forgerock.i18n.LocalizableMessage;
@@ -588,15 +589,22 @@
     return i.hasNext() ? i.next() : null;
   }
 
+  /** Indicates whether this index holds the entries of the provided search, ordered by its sort keys. */
+  boolean isApplicable(final SearchOperation searchOperation, final ServerSideSortRequestControl sortControl)
+      throws DirectoryException
+  {
+    return trusted
+        && searchOperation.getBaseDN().equals(baseDN)
+        && searchOperation.getScope().equals(scope)
+        && searchOperation.getFilter().equals(filter)
+        && sortControl.getSortKeys().equals(sortKeys);
+  }
+
   EntryIDSet evaluate(final ReadableTransaction txn, final SearchOperation searchOperation,
       final ServerSideSortRequestControl sortControl, final VLVRequestControl vlvRequest,
       final StringBuilder debugBuilder) throws DirectoryException, StorageRuntimeException
   {
-    if (!trusted ||
-        !searchOperation.getBaseDN().equals(baseDN) ||
-        !searchOperation.getScope().equals(scope) ||
-        !searchOperation.getFilter().equals(filter) ||
-        !sortControl.getSortKeys().equals(sortKeys))
+    if (!isApplicable(searchOperation, sortControl))
     {
       return null;
     }
@@ -638,7 +646,7 @@
   }
 
   /** Returns the total number of entries (a.k.a records, a.k.a keys) indexed by this VLV index. */
-  private int getEntryCount(final ReadableTransaction txn)
+  int getEntryCount(final ReadableTransaction txn)
   {
     return (int) counter.getCount(txn, COUNT_KEY);
   }
@@ -899,17 +907,28 @@
 
   static ByteString encodeVLVKey(final List<SortKey> sortKeys, final Entry entry, final long entryID)
   {
+    return encodeVLVKey(sortKeys, entry, entryID, Collections.<AttributeType> emptySet());
+  }
+
+  /**
+   * Encodes the VLV key of an entry, leaving out the values of the attribute types in {@code unreadableTypes}, as
+   * if the entry did not have them.
+   */
+  static ByteString encodeVLVKey(final List<SortKey> sortKeys, final Entry entry, final long entryID,
+      final Set<AttributeType> unreadableTypes)
+  {
     final ByteStringBuilder builder = new ByteStringBuilder();
-    encodeVLVKey0(sortKeys, entry, builder);
+    encodeVLVKey0(sortKeys, entry, unreadableTypes, builder);
     builder.appendLong(entryID);
     return builder.toByteString();
   }
 
-  private static void encodeVLVKey0(final List<SortKey> sortKeys, final Entry entry, final ByteStringBuilder builder)
+  private static void encodeVLVKey0(final List<SortKey> sortKeys, final Entry entry,
+      final Set<AttributeType> unreadableTypes, final ByteStringBuilder builder)
   {
     for (final SortKey sortKey : sortKeys)
     {
-      ByteString sortValue = getLowestAttributeValue(entry, sortKey);
+      ByteString sortValue = getLowestAttributeValue(entry, sortKey, unreadableTypes);
       encodeVLVKeyValue(sortValue, builder, sortKey.isReverseOrder());
     }
   }
@@ -918,13 +937,19 @@
    * The RFC states that the lowest value of a multi-valued attribute should be used,
    * regardless of the sort order.
    */
-  private static ByteString getLowestAttributeValue(final Entry entry, final SortKey sortKey)
+  private static ByteString getLowestAttributeValue(final Entry entry, final SortKey sortKey,
+      final Set<AttributeType> unreadableTypes)
   {
     final AttributeDescription attrDesc = AttributeDescription.valueOf(sortKey.getAttributeDescription());
     final MatchingRule matchingRule = getEffectiveOrderingRule(sortKey);
     ByteString sortValue = null;
+    // The values may come from subtypes of the sort attribute, each of which may be unreadable on its own.
     for (Attribute a : entry.getAllAttributes(attrDesc.getAttributeType()))
     {
+      if (unreadableTypes.contains(a.getAttributeDescription().getAttributeType()))
+      {
+        continue;
+      }
       for (ByteString v : a)
       {
         try
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/core/AccessControlConfigManager.java b/opendj-server-legacy/src/main/java/org/opends/server/core/AccessControlConfigManager.java
index 86bbae1..1983823 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/core/AccessControlConfigManager.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/core/AccessControlConfigManager.java
@@ -99,7 +99,8 @@
    */
   public boolean isAccessControlEnabled()
   {
-    return currentConfiguration.isEnabled();
+    // Until initializeAccessControl() has run, getAccessControlHandler() permits everything.
+    return currentConfiguration != null && currentConfiguration.isEnabled();
   }
 
   /**
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/VLVSortConcurrentChangeTest.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/VLVSortConcurrentChangeTest.java
new file mode 100644
index 0000000..a5c330b
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/VLVSortConcurrentChangeTest.java
@@ -0,0 +1,706 @@
+/*
+ * 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.backends.pluggable;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.forgerock.opendj.config.ConfigurationMock.mockCfg;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.opends.server.backends.pluggable.IndexFilter.CURSOR_ENTRY_LIMIT;
+import static org.opends.server.protocols.internal.InternalClientConnection.getRootConnection;
+import static org.opends.server.protocols.internal.Requests.newSearchRequest;
+import static org.opends.server.protocols.ldap.LDAPResultCode.SUCCESS;
+import static org.opends.server.util.CollectionUtils.newTreeSet;
+import static org.opends.server.util.ServerConstants.OID_SERVER_SIDE_SORT_RESPONSE_CONTROL;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+
+import org.forgerock.opendj.config.server.ConfigException;
+import org.forgerock.opendj.ldap.ByteSequence;
+import org.forgerock.opendj.ldap.ByteString;
+import org.forgerock.opendj.ldap.DN;
+import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.ldap.SearchScope;
+import org.forgerock.opendj.server.config.meta.BackendIndexCfgDefn.IndexType;
+import org.forgerock.opendj.server.config.meta.BackendVLVIndexCfgDefn.Scope;
+import org.forgerock.opendj.server.config.server.BackendIndexCfg;
+import org.forgerock.opendj.server.config.server.BackendVLVIndexCfg;
+import org.forgerock.opendj.server.config.server.PDBBackendCfg;
+import org.opends.server.DirectoryServerTestCase;
+import org.opends.server.TestCaseUtils;
+import org.opends.server.api.ClientConnection;
+import org.opends.server.backends.pdb.PDBStorage;
+import org.opends.server.backends.pluggable.spi.AccessMode;
+import org.opends.server.backends.pluggable.spi.Cursor;
+import org.opends.server.backends.pluggable.spi.Importer;
+import org.opends.server.backends.pluggable.spi.ReadOperation;
+import org.opends.server.backends.pluggable.spi.ReadableTransaction;
+import org.opends.server.backends.pluggable.spi.Storage;
+import org.opends.server.backends.pluggable.spi.StorageStatus;
+import org.opends.server.backends.pluggable.spi.TreeName;
+import org.opends.server.backends.pluggable.spi.WriteOperation;
+import org.opends.server.controls.ServerSideSortRequestControl;
+import org.opends.server.controls.ServerSideSortResponseControl;
+import org.opends.server.controls.VLVRequestControl;
+import org.opends.server.core.AddOperation;
+import org.opends.server.core.ServerContext;
+import org.opends.server.protocols.internal.InternalClientConnection;
+import org.opends.server.protocols.internal.InternalSearchOperation;
+import org.opends.server.protocols.internal.SearchRequest;
+import org.opends.server.types.AuthenticationInfo;
+import org.opends.server.types.BackupConfig;
+import org.opends.server.types.BackupDirectory;
+import org.opends.server.types.Control;
+import org.opends.server.types.DirectoryException;
+import org.opends.server.types.Entry;
+import org.opends.server.types.RestoreConfig;
+import org.opends.server.types.SearchResultEntry;
+import org.opends.server.workflowelement.localbackend.LocalBackendSearchOperation;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * Regression tests for GHSA-6f6q-q9gw-4jr4: a sorted search of a caller subject to access control
+ * takes the entries of a VLV index, then reads and sorts them through the caller's access controls.
+ * The JE backend reads without a snapshot, so what the storage holds may change between those reads:
+ * an entry may be deleted, and the index may stop being applicable or grow. The search must then
+ * leave the entry out, or go on as without an index no longer applicable, rather than fail or come
+ * back empty. An index that counts more than the most entries read from an index at once is refused,
+ * whether it did when checked or grew over it since, and so is one that grows over the caller's
+ * lookthrough limit between its count and the read of its entries. An index that applies and holds
+ * no entry gives none.
+ * <p>
+ * The tests run on PDB, whose reads see a snapshot: the storage below makes each change land right
+ * after the read it must follow, and answers the reads of the search after it as the JE backend
+ * would.
+ */
+@SuppressWarnings("javadoc")
+@Test(groups = { "precommit", "pluggablebackend" }, sequential = true)
+public class VLVSortConcurrentChangeTest extends DirectoryServerTestCase
+{
+  private static final String BACKEND_ID = "VLVSortConcurrentChangeTest";
+  private static final DN BASE_DN = DN.valueOf("dc=vlvsort,dc=com");
+  private static final DN PEOPLE_DN = DN.valueOf("ou=people,dc=vlvsort,dc=com");
+  /** Held in lower case, which is how an entry container keys the VLV indexes it holds. */
+  private static final String VLV_INDEX_NAME = "people-by-sn";
+
+  /** Added in this order, other than the order of their "sn" values: alice, bob, charlie. */
+  private static final DN CHARLIE_DN = DN.valueOf("uid=charlie,ou=people,dc=vlvsort,dc=com");
+  private static final DN ALICE_DN = DN.valueOf("uid=alice,ou=people,dc=vlvsort,dc=com");
+  private static final DN BOB_DN = DN.valueOf("uid=bob,ou=people,dc=vlvsort,dc=com");
+  /** Added only by the test that needs a fourth person. */
+  private static final DN DAVE_DN = DN.valueOf("uid=dave,ou=people,dc=vlvsort,dc=com");
+
+  private ServerContext serverContext;
+  /** A user without the bypass-acl privilege, whom the global ACIs let read every entry and "sn". */
+  private InternalClientConnection actorConn;
+  private SteeringBackend backend;
+  private EntryContainer entryContainer;
+  private VLVIndex vlvIndex;
+
+  @BeforeClass
+  public void startServer() throws Exception
+  {
+    TestCaseUtils.startServer();
+    serverContext = TestCaseUtils.getServerContext();
+    actorConn = new InternalClientConnection(new AuthenticationInfo(TestCaseUtils.makeEntry(
+        "dn: uid=actor,dc=vlvsort,dc=com",
+        "objectClass: top",
+        "objectClass: person",
+        "sn: Or",
+        "cn: Act Or"), false));
+  }
+
+  @BeforeMethod
+  public void openBackend() throws Exception
+  {
+    backend = new SteeringBackend();
+    backend.setBackendID(BACKEND_ID);
+    backend.configureBackend(backendCfg(), serverContext);
+    // Start from a pristine on-disk state, whatever the previous test changed in it.
+    backend.storage.removeStorageFiles();
+    backend.openBackend();
+
+    final List<Entry> entries = TestCaseUtils.makeEntries(
+        "dn: " + BASE_DN,
+        "objectClass: top",
+        "objectClass: domain",
+        "dc: vlvsort",
+        "",
+        "dn: " + PEOPLE_DN,
+        "objectClass: top",
+        "objectClass: organizationalUnit",
+        "ou: people",
+        "",
+        "dn: " + CHARLIE_DN,
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "uid: charlie",
+        "sn: Xavier",
+        "cn: Charlie Xavier",
+        "",
+        "dn: " + ALICE_DN,
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "uid: alice",
+        "sn: Baker",
+        "cn: Alice Baker",
+        "",
+        "dn: " + BOB_DN,
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "uid: bob",
+        "sn: Monroe",
+        "cn: Bob Monroe");
+    for (Entry entry : entries)
+    {
+      backend.addEntry(entry, mock(AddOperation.class));
+    }
+
+    entryContainer = backend.getRootContainer().getEntryContainer(BASE_DN);
+    vlvIndex = entryContainer.getVLVIndex(VLV_INDEX_NAME);
+    // Created with the backend empty, so trusted: otherwise no search would take it.
+    assertThat(vlvIndex.isTrusted()).as("the VLV index trusted").isTrue();
+  }
+
+  @AfterMethod(alwaysRun = true)
+  public void closeBackend()
+  {
+    if (backend != null && backend.getRootContainer() != null)
+    {
+      backend.finalizeBackend();
+    }
+    backend = null;
+    try
+    {
+      // What a test that failed while the backend opened may have left in the server wide registry.
+      serverContext.getBackendConfigManager().deregisterBaseDN(BASE_DN);
+    }
+    catch (Exception alreadyGone)
+    {
+      // Which is what closing the backend does.
+    }
+  }
+
+  /**
+   * An entry deleted once the VLV index gave the candidates, and before the sort reads it, takes no
+   * part in the sort: the search must not fail on it.
+   */
+  @Test
+  public void sortOverVLVIndexLeavesOutEntryDeletedSinceItsCandidatesWereRead() throws Exception
+  {
+    backend.storage.deleteForTheNextReads(entryContainer.getID2Entry().getName(), entryIDKey(BOB_DN));
+
+    assertThat(sortedPeople(actorConn, PEOPLE_DN, SearchScope.SINGLE_LEVEL)).containsExactly(ALICE_DN, CHARLIE_DN);
+    assertThat(backend.storage.readsOfDeleted).as("reads of the deleted entry").isPositive();
+  }
+
+  /**
+   * The same on the road of the attribute indexes, which every caller takes when no VLV index serves
+   * the search.
+   */
+  @Test
+  public void sortOverAttributeIndexLeavesOutEntryDeletedSinceItsCandidatesWereRead() throws Exception
+  {
+    backend.storage.deleteForTheNextReads(entryContainer.getID2Entry().getName(), entryIDKey(BOB_DN));
+
+    assertThat(sortedPeople(getRootConnection(), BASE_DN, SearchScope.WHOLE_SUBTREE))
+        .containsExactly(ALICE_DN, CHARLIE_DN);
+    assertThat(backend.storage.readsOfDeleted).as("reads of the deleted entry").isPositive();
+  }
+
+  /**
+   * A change of the definition of the VLV index between the check that it serves the search and the
+   * read of its entries makes it no longer applicable: the search must go on as without it.
+   */
+  @Test
+  public void sortGoesOnWithoutVLVIndexRedefinedSinceItWasChecked() throws Exception
+  {
+    backend.storage.afterFirstRead(counterTreeName(),
+        () -> concurrently(() -> vlvIndex.applyConfigurationChange(vlvIndexCfg("-sn"))));
+
+    assertThat(sortedPeople(actorConn, PEOPLE_DN, SearchScope.SINGLE_LEVEL))
+        .containsExactly(ALICE_DN, BOB_DN, CHARLIE_DN);
+    assertThat(backend.storage.changes).as("changes made during the search").isEqualTo(1);
+    assertThat(vlvIndex.isTrusted()).as("the VLV index trusted after the change of its definition").isFalse();
+  }
+
+  /**
+   * A rebuild of the VLV index starting between the check and the read of its entries leaves it
+   * untrusted and, for a read of the JE backend, empty: the search must go on as without it rather
+   * than take the empty index for an empty result.
+   */
+  @Test
+  public void sortGoesOnWithoutVLVIndexWhoseRebuildStartedSinceItWasChecked() throws Exception
+  {
+    final TreeName deleted = emptyTree("vlv." + VLV_INDEX_NAME + ".deleted");
+    backend.storage.afterFirstRead(counterTreeName(), () ->
+    {
+      concurrently(() ->
+      {
+        backend.getRootContainer().getStorage().write(txn -> vlvIndex.setTrusted(txn, false));
+        return null;
+      });
+      backend.storage.answer(vlvIndex.getName(), deleted);
+      backend.storage.answer(counterTreeName(), deleted);
+    });
+
+    assertThat(sortedPeople(actorConn, PEOPLE_DN, SearchScope.SINGLE_LEVEL))
+        .containsExactly(ALICE_DN, BOB_DN, CHARLIE_DN);
+    assertThat(backend.storage.changes).as("changes made during the search").isEqualTo(1);
+    assertThat(vlvIndex.isTrusted()).as("the VLV index trusted once its rebuild started").isFalse();
+  }
+
+  /**
+   * The VLV index growing over the most entries read from an index at once between its count and the
+   * read of its entries makes it give none: the search must be refused, as it is when the index
+   * already counts that many, rather than go on as without it and sort as many entries from the
+   * attribute indexes.
+   */
+  @Test
+  public void sortOverVLVIndexGrownOverTheEntryLimitSinceItWasCountedIsRefused() throws Exception
+  {
+    final TreeName grown = counterHolding("counter.vlv." + VLV_INDEX_NAME + ".grown", CURSOR_ENTRY_LIMIT + 1);
+    backend.storage.afterFirstRead(counterTreeName(), () -> backend.storage.answer(counterTreeName(), grown));
+
+    final InternalSearchOperation search = searchPeople(actorConn, PEOPLE_DN, SearchScope.SINGLE_LEVEL);
+    assertThat(search.getResultCode()).isEqualTo(ResultCode.ADMIN_LIMIT_EXCEEDED);
+    assertThat(search.getSearchEntries()).isEmpty();
+    assertThat(backend.storage.changes).as("changes made during the search").isEqualTo(1);
+  }
+
+  /**
+   * A VLV index that stops being applicable is left aside whatever it counts: redefined between the
+   * check and the read of its entries while it also grows over the most entries read from an index at
+   * once, the search must still go on as without it rather than be refused.
+   */
+  @Test
+  public void sortGoesOnWithoutVLVIndexRedefinedSinceItWasCheckedThoughItGrewOverTheEntryLimit() throws Exception
+  {
+    final TreeName grown = counterHolding("counter.vlv." + VLV_INDEX_NAME + ".grown", CURSOR_ENTRY_LIMIT + 1);
+    backend.storage.afterFirstRead(counterTreeName(), () ->
+    {
+      concurrently(() -> vlvIndex.applyConfigurationChange(vlvIndexCfg("-sn")));
+      backend.storage.answer(counterTreeName(), grown);
+    });
+
+    assertThat(sortedPeople(actorConn, PEOPLE_DN, SearchScope.SINGLE_LEVEL))
+        .containsExactly(ALICE_DN, BOB_DN, CHARLIE_DN);
+    assertThat(backend.storage.changes).as("changes made during the search").isEqualTo(1);
+  }
+
+  /**
+   * An applicable VLV index whose tree gives none of the entries its count still holds, a state no
+   * read should see, is neither refused nor taken for empty: the search must go on as without it.
+   */
+  @Test
+  public void sortGoesOnWithoutVLVIndexHoldingNoneOfTheEntriesItCounts() throws Exception
+  {
+    backend.storage.answer(vlvIndex.getName(), emptyTree("vlv." + VLV_INDEX_NAME + ".emptied"));
+
+    assertThat(sortedPeople(actorConn, PEOPLE_DN, SearchScope.SINGLE_LEVEL))
+        .containsExactly(ALICE_DN, BOB_DN, CHARLIE_DN);
+  }
+
+  /**
+   * Sorting the entries of a VLV index through the caller's access controls reads every one of them:
+   * a caller without a lookthrough limit, as an internal one, may still not read more than the most
+   * entries read from an index at once, so an index counting more is refused.
+   */
+  @Test
+  public void sortOverVLVIndexCountingOverTheEntryLimitIsRefused() throws Exception
+  {
+    backend.storage.answer(counterTreeName(),
+        counterHolding("counter.vlv." + VLV_INDEX_NAME + ".over", CURSOR_ENTRY_LIMIT + 1));
+
+    final InternalSearchOperation search = searchPeople(actorConn, PEOPLE_DN, SearchScope.SINGLE_LEVEL);
+    assertThat(search.getResultCode()).isEqualTo(ResultCode.ADMIN_LIMIT_EXCEEDED);
+    assertThat(search.getSearchEntries()).isEmpty();
+  }
+
+  /**
+   * The VLV index growing between its count, checked against the caller's lookthrough limit, and the
+   * read of its entries leaves it holding more entries than the caller may examine: the search must
+   * be refused rather than sort them all, even though the VLV page it asks for is smaller.
+   */
+  @Test
+  public void sortOverVLVIndexGrownOverTheLookthroughLimitSinceItWasCountedIsRefused() throws Exception
+  {
+    // The index holds a fourth person, as if added right after its count read three.
+    backend.addEntry(TestCaseUtils.makeEntry(
+        "dn: " + DAVE_DN,
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "uid: dave",
+        "sn: Delaney",
+        "cn: Dave Delaney"), mock(AddOperation.class));
+    backend.storage.answer(counterTreeName(), counterHolding("counter.vlv." + VLV_INDEX_NAME + ".before", 3));
+    backend.storage.afterFirstRead(counterTreeName(),
+        () -> backend.storage.answer(counterTreeName(), counterTreeName()));
+
+    final InternalSearchOperation search = searchPeople(
+        actorLimitedTo(3), PEOPLE_DN, SearchScope.SINGLE_LEVEL, new VLVRequestControl(0, 0, 1, 0));
+    assertThat(search.getResultCode()).isEqualTo(ResultCode.ADMIN_LIMIT_EXCEEDED);
+    assertThat(search.getSearchEntries()).isEmpty();
+    assertThat(backend.storage.changes).as("changes made during the search").isEqualTo(1);
+  }
+
+  /**
+   * An applicable VLV index holding no entry answers the search with none, sorted: the search must
+   * neither go on as without it, through the attribute indexes, which here still hold the people, nor
+   * be refused.
+   */
+  @Test
+  public void sortOverEmptyVLVIndexGivesNoEntry() throws Exception
+  {
+    final TreeName empty = emptyTree("vlv." + VLV_INDEX_NAME + ".empty");
+    backend.storage.answer(vlvIndex.getName(), empty);
+    backend.storage.answer(counterTreeName(), empty);
+
+    final InternalSearchOperation search = searchPeople(actorConn, PEOPLE_DN, SearchScope.SINGLE_LEVEL);
+    assertThat(search.getSearchEntries()).isEmpty();
+    assertThat(sortResultCode(search)).as("result code of the sort response control").isEqualTo(SUCCESS);
+  }
+
+  /** The actor, held to the provided lookthrough limit, which an internal connection never holds otherwise. */
+  private InternalClientConnection actorLimitedTo(int lookthroughLimit) throws Exception
+  {
+    final InternalClientConnection conn = new InternalClientConnection(actorConn.getAuthenticationInfo());
+    // An internal connection ignores the setter of the limit, which its connection keeps in a field of its own.
+    final Field lookthroughLimitField = ClientConnection.class.getDeclaredField("lookthroughLimit");
+    lookthroughLimitField.setAccessible(true);
+    lookthroughLimitField.setInt(conn, lookthroughLimit);
+    return conn;
+  }
+
+  /** Sorts the people on "sn", with a critical sort control and the provided controls, straight through the backend. */
+  private InternalSearchOperation searchPeople(InternalClientConnection conn, DN baseDN, SearchScope scope,
+      Control... controls) throws Exception
+  {
+    final SearchRequest request =
+        newSearchRequest(baseDN, scope, "(sn=*)").addControl(new ServerSideSortRequestControl(true, "sn"));
+    for (Control control : controls)
+    {
+      request.addControl(control);
+    }
+    final InternalSearchOperation search = new InternalSearchOperation(conn, -1, -1, request);
+    backend.search(new LocalBackendSearchOperation(search));
+    return search;
+  }
+
+  private List<DN> sortedPeople(InternalClientConnection conn, DN baseDN, SearchScope scope) throws Exception
+  {
+    final List<DN> dns = new ArrayList<>();
+    for (SearchResultEntry entry : searchPeople(conn, baseDN, scope).getSearchEntries())
+    {
+      dns.add(entry.getName());
+    }
+    return dns;
+  }
+
+  /** The result code of the one sort response control of the search. */
+  private static int sortResultCode(InternalSearchOperation search)
+  {
+    final List<Integer> resultCodes = new ArrayList<>();
+    for (Control control : search.getResponseControls())
+    {
+      if (OID_SERVER_SIDE_SORT_RESPONSE_CONTROL.equals(control.getOID()))
+      {
+        resultCodes.add(((ServerSideSortResponseControl) control).getResultCode());
+      }
+    }
+    assertThat(resultCodes).as("result codes of the sort response controls").hasSize(1);
+    return resultCodes.get(0);
+  }
+
+  private ByteString entryIDKey(DN dn) throws Exception
+  {
+    return backend.getRootContainer().getStorage().read(txn -> entryContainer.getDN2ID().get(txn, dn)).toByteString();
+  }
+
+  /** The tree the VLV index counts its entries in, named as the VLV index names it. */
+  private TreeName counterTreeName()
+  {
+    return new TreeName(entryContainer.getTreePrefix(), "counter.vlv." + VLV_INDEX_NAME);
+  }
+
+  private TreeName emptyTree(String name) throws Exception
+  {
+    final TreeName tree = new TreeName(entryContainer.getTreePrefix(), name);
+    backend.getRootContainer().getStorage().write(txn -> txn.openTree(tree, true));
+    return tree;
+  }
+
+  /** A tree counting entries as the VLV index counts its own, holding the provided count. */
+  private TreeName counterHolding(String name, long count) throws Exception
+  {
+    final TreeName tree = emptyTree(name);
+    // The key the VLV index keeps its count under is its own.
+    final Field countKeyField = VLVIndex.class.getDeclaredField("COUNT_KEY");
+    countKeyField.setAccessible(true);
+    final ByteString countKey = (ByteString) countKeyField.get(null);
+    final ShardedCounter counter = new ShardedCounter(tree);
+    backend.getRootContainer().getStorage().write(txn -> counter.addCount(txn, countKey, count));
+    return tree;
+  }
+
+  /** Makes a change from another thread, as a concurrent operation does, and waits until it is done. */
+  private static void concurrently(Callable<?> change)
+  {
+    final FutureTask<?> task = new FutureTask<>(change);
+    new Thread(task, "concurrent change").start();
+    try
+    {
+      task.get(30, TimeUnit.SECONDS);
+    }
+    catch (Exception e)
+    {
+      throw new AssertionError("the concurrent change failed", e);
+    }
+  }
+
+  private PDBBackendCfg backendCfg() throws ConfigException
+  {
+    final PDBBackendCfg cfg = mockCfg(PDBBackendCfg.class);
+    when(cfg.dn()).thenReturn(DN.valueOf("ds-cfg-backend-id=" + BACKEND_ID + ",cn=Backends,cn=config"));
+    when(cfg.getBackendId()).thenReturn(BACKEND_ID);
+    when(cfg.getDBDirectory()).thenReturn(BACKEND_ID);
+    when(cfg.getDBDirectoryPermissions()).thenReturn("755");
+    when(cfg.getDBCacheSize()).thenReturn(0L);
+    when(cfg.getDBCachePercent()).thenReturn(20);
+    when(cfg.getBaseDN()).thenReturn(newTreeSet(BASE_DN));
+    // Built before they are handed over: stubbing a mock from inside a when() of another mock leaves
+    // that when() unfinished.
+    final BackendIndexCfg snIndexCfg = snPresenceIndexCfg();
+    final BackendVLVIndexCfg vlvIndexCfg = vlvIndexCfg("sn");
+    // The index a search goes on with, once it goes on without the VLV index.
+    when(cfg.listBackendIndexes()).thenReturn(new String[] { "sn" });
+    when(cfg.getBackendIndex("sn")).thenReturn(snIndexCfg);
+    when(cfg.listBackendVLVIndexes()).thenReturn(new String[] { VLV_INDEX_NAME });
+    when(cfg.getBackendVLVIndex(VLV_INDEX_NAME)).thenReturn(vlvIndexCfg);
+    return cfg;
+  }
+
+  private BackendIndexCfg snPresenceIndexCfg()
+  {
+    final BackendIndexCfg cfg = mock(BackendIndexCfg.class);
+    when(cfg.getIndexType()).thenReturn(newTreeSet(IndexType.PRESENCE));
+    when(cfg.getAttribute()).thenReturn(serverContext.getSchema().getAttributeType("sn"));
+    when(cfg.getIndexEntryLimit()).thenReturn(4000);
+    when(cfg.getSubstringLength()).thenReturn(6);
+    return cfg;
+  }
+
+  /** The people sorted on the provided order, which a search of them sorted on "sn" matches as "sn". */
+  private BackendVLVIndexCfg vlvIndexCfg(String sortOrder)
+  {
+    final BackendVLVIndexCfg cfg = mock(BackendVLVIndexCfg.class);
+    when(cfg.getName()).thenReturn(VLV_INDEX_NAME);
+    when(cfg.getBaseDN()).thenReturn(PEOPLE_DN);
+    when(cfg.getScope()).thenReturn(Scope.SINGLE_LEVEL);
+    when(cfg.getFilter()).thenReturn("(sn=*)");
+    when(cfg.getSortOrder()).thenReturn(sortOrder);
+    return cfg;
+  }
+
+  /** A PDB backend whose storage is a {@link SteeringStorage}. */
+  private static final class SteeringBackend extends BackendImpl<PDBBackendCfg>
+  {
+    private SteeringStorage storage;
+
+    @Override
+    protected Storage configureStorage(PDBBackendCfg cfg, ServerContext serverContext) throws ConfigException
+    {
+      storage = new SteeringStorage(new PDBStorage(cfg, serverContext));
+      return storage;
+    }
+  }
+
+  /**
+   * Decorates a {@link Storage} so that its reads see what a concurrent operation changed after the
+   * read began, as reads of the JE backend do: a record deleted, a tree left in another state, which
+   * the reads then get from another tree holding that state. A change may also be set to land right
+   * after the first cursor a read opens on a given tree.
+   */
+  private static final class SteeringStorage implements Storage
+  {
+    private final Storage delegate;
+    private final Map<TreeName, TreeName> answeredFrom = new HashMap<>();
+    private TreeName deletedFrom;
+    private ByteString deletedKey;
+    private int readsOfDeleted;
+    private TreeName changedAfter;
+    private Runnable change;
+    private int changes;
+
+    SteeringStorage(Storage delegate)
+    {
+      this.delegate = delegate;
+    }
+
+    void deleteForTheNextReads(TreeName treeName, ByteString key)
+    {
+      deletedFrom = treeName;
+      deletedKey = key;
+    }
+
+    /** Answers the reads of a tree from now on from another tree, which holds the state it was left in. */
+    void answer(TreeName treeName, TreeName from)
+    {
+      answeredFrom.put(treeName, from);
+    }
+
+    void afterFirstRead(TreeName treeName, Runnable change)
+    {
+      this.changedAfter = treeName;
+      this.change = change;
+    }
+
+    private TreeName answering(TreeName treeName)
+    {
+      final TreeName from = answeredFrom.get(treeName);
+      return from != null ? from : treeName;
+    }
+
+    @Override
+    public <T> T read(final ReadOperation<T> readOperation) throws Exception
+    {
+      return delegate.read(txn -> readOperation.run(new ReadableTransaction()
+      {
+        @Override
+        public ByteString read(TreeName treeName, ByteSequence key)
+        {
+          if (treeName.equals(deletedFrom) && deletedKey.equals(key))
+          {
+            readsOfDeleted++;
+            return null;
+          }
+          return txn.read(answering(treeName), key);
+        }
+
+        @Override
+        public Cursor<ByteString, ByteString> openCursor(TreeName treeName)
+        {
+          final Cursor<ByteString, ByteString> cursor = txn.openCursor(answering(treeName));
+          if (change != null && treeName.equals(changedAfter))
+          {
+            final Runnable landing = change;
+            change = null;
+            changes++;
+            landing.run();
+          }
+          return cursor;
+        }
+
+        @Override
+        public long getRecordCount(TreeName treeName)
+        {
+          return txn.getRecordCount(answering(treeName));
+        }
+
+        @Override
+        public boolean treeExists(TreeName treeName)
+        {
+          return txn.treeExists(answering(treeName));
+        }
+      }));
+    }
+
+    @Override
+    public void write(WriteOperation writeOperation) throws Exception
+    {
+      delegate.write(writeOperation);
+    }
+
+    @Override
+    public Importer startImport() throws ConfigException
+    {
+      return delegate.startImport();
+    }
+
+    @Override
+    public void open(AccessMode accessMode) throws Exception
+    {
+      delegate.open(accessMode);
+    }
+
+    @Override
+    public void removeStorageFiles()
+    {
+      delegate.removeStorageFiles();
+    }
+
+    @Override
+    public StorageStatus getStorageStatus()
+    {
+      return delegate.getStorageStatus();
+    }
+
+    @Override
+    public boolean supportsBackupAndRestore()
+    {
+      return delegate.supportsBackupAndRestore();
+    }
+
+    @Override
+    public void createBackup(BackupConfig backupConfig) throws DirectoryException
+    {
+      delegate.createBackup(backupConfig);
+    }
+
+    @Override
+    public void removeBackup(BackupDirectory backupDirectory, String backupID) throws DirectoryException
+    {
+      delegate.removeBackup(backupDirectory, backupID);
+    }
+
+    @Override
+    public void restoreBackup(RestoreConfig restoreConfig) throws DirectoryException
+    {
+      delegate.restoreBackup(restoreConfig);
+    }
+
+    @Override
+    public Set<TreeName> listTrees()
+    {
+      return delegate.listTrees();
+    }
+
+    @Override
+    public void close()
+    {
+      delegate.close();
+    }
+  }
+}
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/controls/VLVSortAccessControlTest.java b/opendj-server-legacy/src/test/java/org/opends/server/controls/VLVSortAccessControlTest.java
new file mode 100644
index 0000000..7751111
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/controls/VLVSortAccessControlTest.java
@@ -0,0 +1,777 @@
+/*
+ * 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.opends.server.util.ServerConstants.*;
+import static org.testng.Assert.*;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.forgerock.opendj.ldap.AttributeDescription;
+import org.forgerock.opendj.ldap.ByteString;
+import org.forgerock.opendj.ldap.Connection;
+import org.forgerock.opendj.ldap.DN;
+import org.forgerock.opendj.ldap.DecodeOptions;
+import org.forgerock.opendj.ldap.LDAPConnectionFactory;
+import org.forgerock.opendj.ldap.LdapException;
+import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.ldap.SearchScope;
+import org.forgerock.opendj.ldap.controls.VirtualListViewRequestControl;
+import org.forgerock.opendj.ldap.controls.VirtualListViewResponseControl;
+import org.forgerock.opendj.ldap.responses.Result;
+import org.opends.server.TestCaseUtils;
+import org.opends.server.api.AccessControlHandler;
+import org.opends.server.core.AccessControlConfigManager;
+import org.opends.server.protocols.internal.InternalClientConnection;
+import org.opends.server.protocols.internal.InternalSearchOperation;
+import org.opends.server.protocols.internal.SearchRequest;
+import org.opends.server.protocols.ldap.LDAPControl;
+import org.opends.server.types.Control;
+import org.opends.server.types.SearchResultEntry;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+/**
+ * Regression test for GHSA-6f6q-q9gw-4jr4: the server-side sort and virtual list view controls
+ * disclosed entries and attribute values the caller is not allowed to read, through the order of
+ * the results, the VLV target position and the VLV content count.
+ * <p>
+ * The sort keys used to be built from the raw backend entries, and a VLV index answers from its
+ * stored keys, both before the access controls that strip unreadable attributes and entries from
+ * the returned results. Sorting on {@code userPassword} with a VLV assertion thus recovered another
+ * user's password verifier byte by byte. For a caller subject to access control, an entry it may
+ * not see now takes no part in the sort, and the values of an attribute it may not read on an entry
+ * take no part in its sort key.
+ * <p>
+ * The tests hide attributes from an ordinary user with ACIs rather than sorting on a salted
+ * {@code userPassword}, whose stored order is random, so that both the leaks and their absence are
+ * deterministic: the people are added in an order (charlie, alice, bob) other than the order of
+ * their hidden values.
+ */
+@SuppressWarnings("javadoc")
+public class VLVSortAccessControlTest extends ControlsTestCase
+{
+  private static final String BASE_DN = "dc=example,dc=com";
+  private static final String PEOPLE_DN = "ou=people," + BASE_DN;
+  private static final String BULK_DN = "ou=bulk," + BASE_DN;
+  private static final String STAR_DN = "ou=star," + BASE_DN;
+  private static final String ACTOR_DN = "uid=actor," + BASE_DN;
+  private static final String LIMITED_DN = "uid=limited," + BASE_DN;
+  private static final String EXACT_DN = "uid=exact," + BASE_DN;
+  private static final String UNLIMITED_DN = "uid=unlimited," + BASE_DN;
+  private static final String PASSWORD = "Actor-Secret-1";
+
+  /**
+   * One over the default index entry limit, so that neither the filter of the bulk searches nor
+   * their scope yields candidate entries: only the VLV index of the bulk entries can.
+   */
+  private static final int BULK_SIZE = 4001;
+  /** Lookthrough limit of the limited user, under {@link #BULK_SIZE}. */
+  private static final int LIMITED_LOOKTHROUGH = 100;
+
+  private static final DN CHARLIE_DN = DN.valueOf("uid=charlie," + PEOPLE_DN);
+  private static final DN ALICE_DN = DN.valueOf("uid=alice," + PEOPLE_DN);
+  private static final DN BOB_DN = DN.valueOf("uid=bob," + PEOPLE_DN);
+  private static final DN HIDDEN_DN = DN.valueOf("uid=hidden," + PEOPLE_DN);
+  private static final DN STAR_FIRST_DN = DN.valueOf("uid=first," + STAR_DN);
+  private static final DN STAR_SECOND_DN = DN.valueOf("uid=second," + STAR_DN);
+  private static final DN STAR_THIRD_DN = DN.valueOf("uid=third," + STAR_DN);
+  private static final String VERSIONS_DN = "ou=versions," + BASE_DN;
+
+  /** The global ACIs that name attributes, which keep the "all attributes matched" shortcut off. */
+  private static final String[] ATTRIBUTE_GLOBAL_ACIS = {
+    "(targetattr!=\"userPassword||authPassword||debugsearchindex||changes||changeNumber||changeType||changeTime"
+        + "||targetDN||newRDN||newSuperior||deleteOldRDN\")(version 3.0; acl \"Anonymous read access\"; "
+        + "allow (read,search,compare) userdn=\"ldap:///anyone\";)",
+    "(targetattr=\"userPassword||authPassword\")(version 3.0; acl \"Self entry read\"; "
+        + "allow (read,search,compare) userdn=\"ldap:///self\";)",
+    "(targetattr=\"createTimestamp||creatorsName||modifiersName||modifyTimestamp||entryDN||entryUUID"
+        + "||subschemaSubentry||etag||governingStructureRule||structuralObjectClass||hasSubordinates"
+        + "||numSubordinates||isMemberOf\")(version 3.0; acl \"User-Visible Operational Attributes\"; "
+        + "allow (read,search,compare) userdn=\"ldap:///anyone\";)",
+  };
+  /**
+   * A rule on all user and all operational attributes: once it replaces the global ACIs that name
+   * attributes, it alone matches an entry without ACIs of its own, which sets both halves of the shortcut.
+   */
+  private static final String ALL_ATTRIBUTES_GLOBAL_ACI = "(targetattr=\"*||+\")(version 3.0; "
+      + "acl \"Read everything\"; allow (read,search,compare) userdn=\"ldap:///anyone\";)";
+
+  private InternalClientConnection actorConn;
+
+  @BeforeClass
+  public void setUp() throws Exception
+  {
+    TestCaseUtils.startServer();
+
+    // A VLV index is trusted, and thus used, only when it is created while the backend is empty.
+    TestCaseUtils.clearBackend("userRoot");
+    TestCaseUtils.dsconfig("create-backend-vlv-index",
+        "--backend-name", "userRoot",
+        "--index-name", "people-by-description",
+        "--set", "base-dn:" + PEOPLE_DN,
+        "--set", "scope:single-level",
+        "--set", "filter:(objectClass=person)",
+        "--set", "sort-order:description");
+    TestCaseUtils.dsconfig("create-backend-vlv-index",
+        "--backend-name", "userRoot",
+        "--index-name", "bulk-by-sn",
+        "--set", "base-dn:" + BULK_DN,
+        "--set", "scope:single-level",
+        "--set", "filter:(employeeNumber=*)",
+        "--set", "sort-order:sn");
+
+    TestCaseUtils.addEntries(
+        "dn: " + BASE_DN,
+        "objectClass: top",
+        "objectClass: domain",
+        "dc: example",
+        "",
+        // Inherited by the people below. The actor may neither read nor search "description"
+        // and "cn", and may search but not read "employeeNumber".
+        "dn: " + PEOPLE_DN,
+        "objectClass: top",
+        "objectClass: organizationalUnit",
+        "ou: people",
+        "aci: (targetattr=\"description||cn\")(version 3.0; acl \"deny description and cn to actor\"; "
+            + "deny(read,search,compare) userdn=\"ldap:///" + ACTOR_DN + "\";)",
+        "aci: (targetattr=\"employeeNumber\")(version 3.0; acl \"deny reading employeeNumber to actor\"; "
+            + "deny(read) userdn=\"ldap:///" + ACTOR_DN + "\";)",
+        "",
+        "dn: " + ACTOR_DN,
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "uid: actor",
+        "sn: Or",
+        "cn: Act Or",
+        "userPassword: " + PASSWORD,
+        "",
+        "dn: " + LIMITED_DN,
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "uid: limited",
+        "sn: Ited",
+        "cn: Lim Ited",
+        "userPassword: " + PASSWORD,
+        "ds-rlim-lookthrough-limit: " + LIMITED_LOOKTHROUGH,
+        "",
+        "dn: " + EXACT_DN,
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "uid: exact",
+        "sn: Act",
+        "cn: Ex Act",
+        "userPassword: " + PASSWORD,
+        "ds-rlim-lookthrough-limit: " + BULK_SIZE,
+        "",
+        "dn: " + UNLIMITED_DN,
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "uid: unlimited",
+        "sn: Limited",
+        "cn: Un Limited",
+        "userPassword: " + PASSWORD,
+        "ds-rlim-lookthrough-limit: 0",
+        "",
+        // Hidden values in order charlie, bob, alice for description, cn and employeeNumber alike,
+        // sn in order alice, bob, charlie.
+        "dn: " + CHARLIE_DN,
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "uid: charlie",
+        "sn: Xavier",
+        "cn: A Charlie",
+        "description: aaa",
+        "employeeNumber: 1",
+        "",
+        "dn: " + ALICE_DN,
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "uid: alice",
+        "sn: Baker",
+        "cn: C Alice",
+        "description: zzz",
+        "employeeNumber: 3",
+        "",
+        "dn: " + BOB_DN,
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "uid: bob",
+        "sn: Monroe",
+        "cn: B Bob",
+        "description: mmm",
+        "employeeNumber: 2",
+        "",
+        // The actor may not see this entry at all.
+        "dn: " + HIDDEN_DN,
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "uid: hidden",
+        "sn: Hidden",
+        "cn: Hid Den",
+        "description: bbb",
+        "aci: (targetattr=\"*\")(version 3.0; acl \"hide entry from actor\"; "
+            + "deny(all) userdn=\"ldap:///" + ACTOR_DN + "\";)",
+        "",
+        // The first entry hides its description from the actor, the third one its description and
+        // entryUUID; once the global ACIs that name attributes are gone, only rules on all
+        // attributes match the second one.
+        "dn: " + STAR_DN,
+        "objectClass: top",
+        "objectClass: organizationalUnit",
+        "ou: star",
+        "",
+        "dn: " + STAR_FIRST_DN,
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "uid: first",
+        "sn: Alpha",
+        "cn: First Alpha",
+        "description: secret",
+        "aci: (targetattr=\"description\")(version 3.0; acl \"deny description to actor\"; "
+            + "deny(read,search,compare) userdn=\"ldap:///" + ACTOR_DN + "\";)",
+        "",
+        "dn: " + STAR_SECOND_DN,
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "uid: second",
+        "sn: Beta",
+        "cn: Second Beta",
+        "",
+        "dn: " + STAR_THIRD_DN,
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "uid: third",
+        "sn: Gamma",
+        "cn: Third Gamma",
+        "description: secret",
+        "aci: (targetattr=\"description\")(version 3.0; acl \"deny description to actor\"; "
+            + "deny(read,search,compare) userdn=\"ldap:///" + ACTOR_DN + "\";)",
+        "aci: (targetattr=\"entryUUID\")(version 3.0; acl \"deny entryUUID to actor\"; "
+            + "deny(read,search,compare) userdn=\"ldap:///" + ACTOR_DN + "\";)",
+        "",
+        // Its rule hides "description" from the actor on the versions of an entry whose content
+        // matches its target filter alone, whatever their DN.
+        "dn: " + VERSIONS_DN,
+        "objectClass: top",
+        "objectClass: organizationalUnit",
+        "ou: versions",
+        "aci: (targetattr=\"description\")(targetfilter=\"(employeeType=secret)\")(version 3.0; "
+            + "acl \"deny description of secret entries to actor\"; "
+            + "deny(read,search,compare) userdn=\"ldap:///" + ACTOR_DN + "\";)",
+        "",
+        "dn: " + BULK_DN,
+        "objectClass: top",
+        "objectClass: organizationalUnit",
+        "ou: bulk");
+
+    final List<String> bulk = new ArrayList<>();
+    for (int i = 0; i < BULK_SIZE; i++)
+    {
+      bulk.addAll(Arrays.asList(
+          "dn: uid=bulk" + i + "," + BULK_DN,
+          "objectClass: top",
+          "objectClass: person",
+          "objectClass: organizationalPerson",
+          "objectClass: inetOrgPerson",
+          "uid: bulk" + i,
+          "sn: " + String.format("Bulk%05d", i),
+          "cn: Bulk " + i,
+          "employeeNumber: " + i,
+          ""));
+    }
+    TestCaseUtils.addEntries(bulk.toArray(new String[0]));
+
+    actorConn = new InternalClientConnection(DN.valueOf(ACTOR_DN));
+  }
+
+  @AfterClass
+  public void tearDown() throws Exception
+  {
+    TestCaseUtils.dsconfig("delete-backend-vlv-index", "--backend-name", "userRoot",
+        "--index-name", "people-by-description");
+    TestCaseUtils.dsconfig("delete-backend-vlv-index", "--backend-name", "userRoot",
+        "--index-name", "bulk-by-sn");
+    TestCaseUtils.clearBackend("userRoot", BASE_DN);
+  }
+
+  private static List<DN> dns(InternalSearchOperation search)
+  {
+    final List<DN> dns = new ArrayList<>();
+    for (SearchResultEntry e : search.getSearchEntries())
+    {
+      dns.add(e.getName());
+    }
+    return dns;
+  }
+
+  private static InternalSearchOperation search(InternalClientConnection conn, SearchRequest request)
+  {
+    final InternalSearchOperation search = conn.processSearch(request);
+    assertEquals(search.getResultCode(), ResultCode.SUCCESS, String.valueOf(search.getErrorMessage()));
+    return search;
+  }
+
+  /** People in the whole subtree, which no VLV index serves: the sort runs in memory. */
+  private static List<DN> sortedPeople(InternalClientConnection conn, String sortOrder) throws Exception
+  {
+    return dns(search(conn, newSearchRequest(PEOPLE_DN, SearchScope.WHOLE_SUBTREE, "(objectClass=person)")
+        .addControl(new ServerSideSortRequestControl(sortOrder))));
+  }
+
+  private static List<Control> responseControls(InternalSearchOperation search, String oid)
+  {
+    final List<Control> controls = new ArrayList<>();
+    for (Control c : search.getResponseControls())
+    {
+      if (oid.equals(c.getOID()))
+      {
+        controls.add(c);
+      }
+    }
+    return controls;
+  }
+
+  private static VLVResponseControl vlvResponse(InternalSearchOperation search) throws Exception
+  {
+    final List<Control> controls = responseControls(search, OID_VLV_RESPONSE_CONTROL);
+    assertEquals(controls.size(), 1, "Expected one VLV response control");
+    final Control c = controls.get(0);
+    return c instanceof LDAPControl
+        ? VLVResponseControl.DECODER.decode(c.isCritical(), ((LDAPControl) c).getValue())
+        : (VLVResponseControl) c;
+  }
+
+  /** The first page of the bulk entries sorted on "sn", which only their VLV index can give. */
+  private static org.forgerock.opendj.ldap.requests.SearchRequest bulkPageRequest()
+  {
+    return org.forgerock.opendj.ldap.requests.Requests.newSearchRequest(
+            BULK_DN, SearchScope.SINGLE_LEVEL, "(employeeNumber=*)", "1.1")
+        .addControl(org.forgerock.opendj.ldap.controls.ServerSideSortRequestControl.newControl(true, "sn"))
+        .addControl(VirtualListViewRequestControl.newOffsetControl(true, 1, 0, 0, 0, null));
+  }
+
+  /** Requests {@link #bulkPageRequest()} over LDAP as the provided user, so that its resource limits apply. */
+  private static Result bulkPageOverLdap(String bindDN) throws Exception
+  {
+    final LDAPConnectionFactory factory = new LDAPConnectionFactory("localhost", TestCaseUtils.getServerLdapPort());
+    try (Connection connection = factory.getConnection())
+    {
+      connection.bind(bindDN, PASSWORD.toCharArray());
+      return connection.search(bulkPageRequest(),
+          new ArrayList<org.forgerock.opendj.ldap.responses.SearchResultEntry>());
+    }
+    finally
+    {
+      factory.close();
+    }
+  }
+
+  private static void assertBulkPageServed(Result result) throws Exception
+  {
+    assertEquals(result.getResultCode(), ResultCode.SUCCESS);
+    final VirtualListViewResponseControl vlv =
+        result.getControl(VirtualListViewResponseControl.DECODER, new DecodeOptions());
+    assertNotNull(vlv, "Expected a VLV response control");
+    assertEquals(vlv.getContentCount(), BULK_SIZE);
+  }
+
+  private static void replaceAttributeGlobalAcis(String remove, String add)
+  {
+    final List<String> args = new ArrayList<>(Arrays.asList("set-access-control-handler-prop"));
+    for (String aci : ATTRIBUTE_GLOBAL_ACIS)
+    {
+      args.add(remove);
+      args.add("global-aci:" + aci);
+    }
+    args.add(add);
+    args.add("global-aci:" + ALL_ATTRIBUTES_GLOBAL_ACI);
+    TestCaseUtils.dsconfig(args.toArray(new String[0]));
+  }
+
+  /** A version of an entry under {@link #VERSIONS_DN}, which the backend need not hold, all of them of one DN. */
+  private static SearchResultEntry versionOf(String employeeType) throws Exception
+  {
+    return new SearchResultEntry(TestCaseUtils.makeEntry(
+        "dn: uid=versioned," + VERSIONS_DN,
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "uid: versioned",
+        "sn: Sioned",
+        "cn: Ver Sioned",
+        "employeeType: " + employeeType,
+        "description: classified"));
+  }
+
+  /**
+   * The actor may not read "description", so sorting on it must not order the people by its values:
+   * they come in entry ID order (charlie, alice, bob) instead of value order (charlie, bob, alice).
+   */
+  @Test
+  public void sortOnUnreadableAttributeDoesNotLeakOrder() throws Exception
+  {
+    assertEquals(sortedPeople(actorConn, "description"), Arrays.asList(CHARLIE_DN, ALICE_DN, BOB_DN),
+        "sorting on an attribute the caller cannot read must not reveal the order of its values");
+  }
+
+  /** A bypass-acl caller may read everything, so it still gets the value order. */
+  @Test
+  public void sortOnReadableAttributeIsUnchangedForRoot() throws Exception
+  {
+    assertEquals(sortedPeople(getRootConnection(), "description"),
+        Arrays.asList(CHARLIE_DN, HIDDEN_DN, BOB_DN, ALICE_DN));
+  }
+
+  /** The actor may read "sn", so sorting on it still orders by its values. */
+  @Test
+  public void sortOnReadableAttributeStillWorksForActor() throws Exception
+  {
+    assertEquals(sortedPeople(actorConn, "sn"), Arrays.asList(ALICE_DN, BOB_DN, CHARLIE_DN));
+  }
+
+  /**
+   * The actor may read the virtual "entryDN", so sorting on it still orders by its values: the sort
+   * keys must see the virtual attributes of an entry, as the returned entry does.
+   */
+  @Test
+  public void sortOnReadableVirtualAttributeStillWorksForActor() throws Exception
+  {
+    assertEquals(sortedPeople(actorConn, "entryDN:caseIgnoreOrderingMatch"),
+        Arrays.asList(ALICE_DN, BOB_DN, CHARLIE_DN));
+  }
+
+  /**
+   * "name" takes its values from its subtypes "cn" and "sn". The actor may read "name" and "sn" but
+   * not "cn", so the order must follow "sn" (alice, bob, charlie), not the lower "cn" values
+   * (charlie, bob, alice).
+   */
+  @Test
+  public void sortOnSupertypeLeavesOutUnreadableSubtypeValues() throws Exception
+  {
+    assertEquals(sortedPeople(actorConn, "name"), Arrays.asList(ALICE_DN, BOB_DN, CHARLIE_DN),
+        "a sort on a supertype must not follow the values of a subtype the caller cannot read");
+  }
+
+  /**
+   * The actor may search "employeeNumber" but not read it: the sort keys need the right to read,
+   * so the order must not follow its values (charlie, bob, alice).
+   */
+  @Test
+  public void sortOnAttributeDeniedOnlyReadDoesNotLeakOrder() throws Exception
+  {
+    assertEquals(sortedPeople(actorConn, "employeeNumber"), Arrays.asList(CHARLIE_DN, ALICE_DN, BOB_DN));
+  }
+
+  /** Each sort key is checked on its own: the unreadable first key leaves the order to "sn". */
+  @Test
+  public void sortOnSeveralKeysChecksEachKey() throws Exception
+  {
+    assertEquals(sortedPeople(actorConn, "description,sn"), Arrays.asList(ALICE_DN, BOB_DN, CHARLIE_DN));
+  }
+
+  /** The VLV content count must leave out the entry the actor may not see. */
+  @Test
+  public void vlvContentCountLeavesOutEntryHiddenFromCaller() throws Exception
+  {
+    final InternalSearchOperation search = search(actorConn,
+        newSearchRequest(PEOPLE_DN, SearchScope.WHOLE_SUBTREE, "(objectClass=person)")
+            .addControl(new ServerSideSortRequestControl("sn"))
+            .addControl(new VLVRequestControl(0, 3, 1, 0)));
+    assertEquals(vlvResponse(search).getContentCount(), 3);
+    assertEquals(dns(search), Arrays.asList(ALICE_DN, BOB_DN, CHARLIE_DN));
+  }
+
+  /**
+   * This search matches the VLV index of the people. Its keys hold the hidden values (aaa, bbb,
+   * mmm, zzz), so answering from them places the assertion "b" at position 2 of 4. The actor sees
+   * three people and none of their values, so it must get position 1 of 3, with a single sort
+   * response control.
+   */
+  @Test
+  public void vlvTargetPositionFromIndexDoesNotDependOnUnreadableValue() throws Exception
+  {
+    final InternalSearchOperation search = search(actorConn,
+        newSearchRequest(PEOPLE_DN, SearchScope.SINGLE_LEVEL, "(objectClass=person)")
+            .addControl(new ServerSideSortRequestControl("description"))
+            .addControl(new VLVRequestControl(0, 0, ByteString.valueOfUtf8("b"))));
+    final VLVResponseControl vlv = vlvResponse(search);
+    assertEquals(vlv.getContentCount(), 3, "the content count must leave out the hidden entry");
+    assertEquals(vlv.getTargetPosition(), 1, "the target position must not depend on the hidden values");
+    assertEquals(responseControls(search, OID_SERVER_SIDE_SORT_RESPONSE_CONTROL).size(), 1);
+  }
+
+  /**
+   * The VLV index of the people keeps its entries in the order of their hidden values. A negative
+   * offset makes the sort fail; the sort control is not critical, so the search goes on with the
+   * entries unsorted, and they must come in entry ID order, not in the order of the index.
+   */
+  @Test
+  public void sortErrorOverIndexDoesNotReturnEntriesInHiddenValueOrder() throws Exception
+  {
+    final InternalSearchOperation search = search(actorConn,
+        newSearchRequest(PEOPLE_DN, SearchScope.SINGLE_LEVEL, "(objectClass=person)")
+            .addControl(new ServerSideSortRequestControl("description"))
+            .addControl(new VLVRequestControl(0, 3, -1, 0)));
+    assertEquals(dns(search), Arrays.asList(CHARLIE_DN, ALICE_DN, BOB_DN),
+        "after a sort error, the entries of the index must not come in the order of the hidden values");
+  }
+
+  /**
+   * Neither the filter nor the scope of this search gives candidate entries, only the VLV index of
+   * the bulk entries does: the actor must still get its page, sorted through its access controls.
+   */
+  @Test
+  public void vlvOverIndexOnlyEntriesIsStillServedToCaller() throws Exception
+  {
+    final InternalSearchOperation search = search(actorConn,
+        newSearchRequest(BULK_DN, SearchScope.SINGLE_LEVEL, "(employeeNumber=*)")
+            .addControl(new ServerSideSortRequestControl("sn"))
+            .addControl(new VLVRequestControl(0, 0, 1, 0)));
+    assertEquals(vlvResponse(search).getContentCount(), BULK_SIZE);
+    assertEquals(dns(search), Arrays.asList(DN.valueOf("uid=bulk0," + BULK_DN)));
+  }
+
+  /**
+   * Sorting the entries of a VLV index through the caller's access controls reads all of them, so
+   * it must not read more than the caller's lookthrough limit; the refusal answers both controls.
+   */
+  @Test
+  public void vlvOverIndexHonoursCallerLookthroughLimit() throws Exception
+  {
+    try
+    {
+      bulkPageOverLdap(LIMITED_DN);
+      fail("Expected the lookthrough limit of " + LIMITED_LOOKTHROUGH + " to be exceeded");
+    }
+    catch (LdapException e)
+    {
+      final Result result = e.getResult();
+      assertEquals(result.getResultCode(), ResultCode.ADMIN_LIMIT_EXCEEDED);
+      final org.forgerock.opendj.ldap.controls.ServerSideSortResponseControl sort = result.getControl(
+          org.forgerock.opendj.ldap.controls.ServerSideSortResponseControl.DECODER, new DecodeOptions());
+      assertNotNull(sort, "Expected a sort response control");
+      assertEquals(sort.getResult(), ResultCode.ADMIN_LIMIT_EXCEEDED);
+      final VirtualListViewResponseControl vlv =
+          result.getControl(VirtualListViewResponseControl.DECODER, new DecodeOptions());
+      assertNotNull(vlv, "Expected a VLV response control");
+      assertEquals(vlv.getResult(), ResultCode.ADMIN_LIMIT_EXCEEDED);
+    }
+  }
+
+  /** The same refusal for a sort without a VLV request answers the sort control alone. */
+  @Test
+  public void sortOverIndexHonoursCallerLookthroughLimit() throws Exception
+  {
+    final LDAPConnectionFactory factory = new LDAPConnectionFactory("localhost", TestCaseUtils.getServerLdapPort());
+    try (Connection connection = factory.getConnection())
+    {
+      connection.bind(LIMITED_DN, PASSWORD.toCharArray());
+      connection.search(org.forgerock.opendj.ldap.requests.Requests.newSearchRequest(
+              BULK_DN, SearchScope.SINGLE_LEVEL, "(employeeNumber=*)", "1.1")
+              .addControl(org.forgerock.opendj.ldap.controls.ServerSideSortRequestControl.newControl(true, "sn")),
+          new ArrayList<org.forgerock.opendj.ldap.responses.SearchResultEntry>());
+      fail("Expected the lookthrough limit of " + LIMITED_LOOKTHROUGH + " to be exceeded");
+    }
+    catch (LdapException e)
+    {
+      final Result result = e.getResult();
+      assertEquals(result.getResultCode(), ResultCode.ADMIN_LIMIT_EXCEEDED);
+      final org.forgerock.opendj.ldap.controls.ServerSideSortResponseControl sort = result.getControl(
+          org.forgerock.opendj.ldap.controls.ServerSideSortResponseControl.DECODER, new DecodeOptions());
+      assertNotNull(sort, "Expected a sort response control");
+      assertEquals(sort.getResult(), ResultCode.ADMIN_LIMIT_EXCEEDED);
+      assertNull(result.getControl(VirtualListViewResponseControl.DECODER, new DecodeOptions()),
+          "A search without a VLV request must not get a VLV response control");
+    }
+    finally
+    {
+      factory.close();
+    }
+  }
+
+  /** A lookthrough limit equal to the size of the VLV index lets the caller read all of it. */
+  @Test
+  public void vlvOverIndexIsServedAtCallerLookthroughLimit() throws Exception
+  {
+    assertBulkPageServed(bulkPageOverLdap(EXACT_DN));
+  }
+
+  /** No lookthrough limit lets the caller read the whole VLV index. */
+  @Test
+  public void vlvOverIndexIsServedWithoutCallerLookthroughLimit() throws Exception
+  {
+    assertBulkPageServed(bulkPageOverLdap(UNLIMITED_DN));
+  }
+
+  /**
+   * maySend() leaves on the operation a shortcut for the entry about to be returned: when only rules
+   * on all attributes matched, the entry's attributes are not checked one by one. Once the global
+   * ACIs that name attributes are gone, the second entry, outside the page, sets that shortcut while
+   * the sort checks every candidate; the first entry, on the page, must still have its hidden
+   * "description" stripped.
+   */
+  @Test(priority = 1)
+  public void vlvPageEntryKeepsItsAttributeChecksAfterSort() throws Exception
+  {
+    replaceAttributeGlobalAcis("--remove", "--add");
+    try
+    {
+      final InternalSearchOperation search = search(actorConn,
+          newSearchRequest(STAR_DN, SearchScope.SINGLE_LEVEL, "(objectClass=person)")
+              .addAttribute("description")
+              .addControl(new ServerSideSortRequestControl("sn"))
+              .addControl(new VLVRequestControl(0, 0, 1, 0)));
+      assertEquals(dns(search), Arrays.asList(STAR_FIRST_DN));
+      assertFalse(search.getSearchEntries().getFirst().hasAttribute(AttributeDescription.valueOf("description")),
+          "the returned entry must not carry the attribute the caller may not read");
+    }
+    finally
+    {
+      replaceAttributeGlobalAcis("--add", "--remove");
+    }
+  }
+
+  /**
+   * The same shortcut, without any sort: the second entry is returned before the third one, and the
+   * third one must still have its hidden "description" stripped.
+   */
+  @Test(priority = 1)
+  public void eachReturnedEntryKeepsItsAttributeChecks() throws Exception
+  {
+    replaceAttributeGlobalAcis("--remove", "--add");
+    try
+    {
+      final InternalSearchOperation search = search(actorConn,
+          newSearchRequest(STAR_DN, SearchScope.SINGLE_LEVEL, "(objectClass=person)").addAttribute("description"));
+      assertEquals(search.getSearchEntries().size(), 3);
+      for (SearchResultEntry e : search.getSearchEntries())
+      {
+        assertFalse(e.hasAttribute(AttributeDescription.valueOf("description")),
+            e.getName() + " must not carry the attribute the caller may not read");
+      }
+    }
+    finally
+    {
+      replaceAttributeGlobalAcis("--add", "--remove");
+    }
+  }
+
+  /**
+   * The operational attribute half of the same shortcut: the second entry, matched by rules on all
+   * attributes alone, is returned before the third one, which must still have its hidden "entryUUID"
+   * stripped.
+   */
+  @Test(priority = 1)
+  public void eachReturnedEntryKeepsItsOperationalAttributeChecks() throws Exception
+  {
+    replaceAttributeGlobalAcis("--remove", "--add");
+    try
+    {
+      final InternalSearchOperation search = search(actorConn,
+          newSearchRequest(STAR_DN, SearchScope.SINGLE_LEVEL, "(objectClass=person)").addAttribute("+"));
+      assertEquals(dns(search), Arrays.asList(STAR_FIRST_DN, STAR_SECOND_DN, STAR_THIRD_DN));
+      final AttributeDescription entryUUID = AttributeDescription.valueOf("entryUUID");
+      assertTrue(search.getSearchEntries().get(1).hasAttribute(entryUUID),
+          "the caller may read the operational attributes of " + STAR_SECOND_DN);
+      assertFalse(search.getSearchEntries().get(2).hasAttribute(entryUUID),
+          STAR_THIRD_DN + " must not carry the operational attribute the caller may not read");
+    }
+    finally
+    {
+      replaceAttributeGlobalAcis("--add", "--remove");
+    }
+  }
+
+  /**
+   * The shortcut holds the very entry maySend() checked, not its DN: two versions of one entry, which
+   * the interleaved persistent search notifications of two modifications of it hand out, keep their
+   * own checks. Only the later version is matched by rules on all attributes alone; the earlier one,
+   * whose content a target filter rule hides "description" of, must still have it stripped.
+   */
+  @Test(priority = 1)
+  public void entryVersionKeepsItsAttributeChecksAfterAnotherVersionOfItsDN() throws Exception
+  {
+    replaceAttributeGlobalAcis("--remove", "--add");
+    try
+    {
+      final AccessControlHandler<?> handler = AccessControlConfigManager.getInstance().getAccessControlHandler();
+      final InternalSearchOperation search = new InternalSearchOperation(actorConn, nextOperationID(),
+          nextMessageID(), newSearchRequest(VERSIONS_DN, SearchScope.SINGLE_LEVEL, "(objectClass=person)"));
+      final SearchResultEntry secret = versionOf("secret");
+      final SearchResultEntry plain = versionOf("plain");
+      assertTrue(handler.maySend(search, secret));
+      assertTrue(handler.maySend(search, plain));
+
+      final SearchResultEntry filtered = new SearchResultEntry(secret.duplicate(false));
+      handler.filterEntry(search, secret, filtered);
+      assertTrue(filtered.hasAttribute(AttributeDescription.valueOf("sn")),
+          "the caller may read the other attributes of the entry");
+      assertFalse(filtered.hasAttribute(AttributeDescription.valueOf("description")),
+          "a version of an entry must not carry the attribute the caller may not read on it");
+    }
+    finally
+    {
+      replaceAttributeGlobalAcis("--add", "--remove");
+    }
+  }
+
+  /**
+   * With access control disabled, the sort of a non-root caller need not go through it: the VLV
+   * index answers, so its size may exceed the caller's lookthrough limit.
+   */
+  @Test(priority = 2)
+  public void vlvOverIndexIsAnsweredByIndexWithAccessControlDisabled() throws Exception
+  {
+    TestCaseUtils.dsconfig("set-access-control-handler-prop", "--set", "enabled:false");
+    try
+    {
+      assertBulkPageServed(bulkPageOverLdap(LIMITED_DN));
+    }
+    finally
+    {
+      TestCaseUtils.dsconfig("set-access-control-handler-prop", "--set", "enabled:true");
+    }
+  }
+}
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/core/AccessControlConfigManagerTest.java b/opendj-server-legacy/src/test/java/org/opends/server/core/AccessControlConfigManagerTest.java
new file mode 100644
index 0000000..52c8b08
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/core/AccessControlConfigManagerTest.java
@@ -0,0 +1,42 @@
+/*
+ * 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.core;
+
+import static org.testng.Assert.assertFalse;
+
+import java.lang.reflect.Constructor;
+
+import org.testng.annotations.Test;
+
+@SuppressWarnings("javadoc")
+public class AccessControlConfigManagerTest extends CoreTestCase
+{
+  /**
+   * Until initializeAccessControl() has run, the manager holds no configuration and its handler
+   * permits everything: access control is not enabled, which a sorted search asks of every caller
+   * (GHSA-6f6q-q9gw-4jr4), rather than an error.
+   */
+  @Test
+  public void accessControlIsNotEnabledBeforeItIsInitialized() throws Exception
+  {
+    // The server's own manager has been initialized: only a manager of its own has not.
+    final Constructor<AccessControlConfigManager> constructor =
+        AccessControlConfigManager.class.getDeclaredConstructor();
+    constructor.setAccessible(true);
+
+    assertFalse(constructor.newInstance().isAccessControlEnabled());
+  }
+}

--
Gitblit v1.10.0