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/EntryContainer.java |  267 +++++++++++++++++++++++++++++++++++++++++++---------
 1 files changed, 219 insertions(+), 48 deletions(-)

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()];

--
Gitblit v1.10.0