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