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