From af11ab652419eae8bc5c57e2b3badbcf836e92cf Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Wed, 15 Jul 2026 16:41:53 +0300 Subject: [PATCH] [#738] Fix dereferencing an alias that points into another backend LocalBackendSearchOperation resolves the backend once, for the original base DN, and caches it in a field. When the base entry turns out to be an alias, processSearch swaps the base DN and re-enters itself, but never re-resolves the backend, so the recursive call asks the backend of the original base DN for a base DN it does not hold. Aliases whose target lives in the same backend work, which is why this went unnoticed since f7713150. Re-resolve the backend for the aliased DN before recursing. When no backend holds the target, the null check already at the top of processSearch reports NO_SUCH_OBJECT for the new base DN. Make both backends honour the LocalBackend.search contract when handed a base DN they do not hold, which is what made this bug so hard to read: MemoryBackend only reported a missing base entry when handlesEntry() was true and otherwise went on to match the filter against a null entry, answering a base search with a NullPointerException (result 80); the pluggable backend reported the internally used UNDEFINED (-1). Both now return NO_SUCH_OBJECT. UNDEFINED stays the default for the other accessBegin() callers: hasSubordinates() relies on it. Add AliasTestCase coverage for an alias crossing a backend boundary and for an alias whose target has no backend at all, a contract test to PluggableBackendImplTestCase, and MemoryBackendTestCase for the memory backend. --- .../opends/server/backends/MemoryBackend.java | 11 ++- .../backends/pluggable/BackendImpl.java | 24 +++++- .../LocalBackendSearchOperation.java | 6 +- .../backends/MemoryBackendTestCase.java | 77 ++++++++++++++++++ .../PluggableBackendImplTestCase.java | 18 ++++- .../opendj/AliasTestCase.java | 80 ++++++++++++++++++- 6 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 opendj-server-legacy/src/test/java/org/opends/server/backends/MemoryBackendTestCase.java diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/MemoryBackend.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/MemoryBackend.java index fd5f0bce8a..c3e38a459b 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/backends/MemoryBackend.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/MemoryBackend.java @@ -13,6 +13,7 @@ * * Copyright 2006-2008 Sun Microsystems, Inc. * Portions Copyright 2014-2016 ForgeRock AS. + * Portions Copyrighted 2026 3A Systems, LLC. */ package org.opends.server.backends; @@ -494,9 +495,10 @@ public synchronized void search(SearchOperation searchOperation) SearchScope scope = searchOperation.getScope(); SearchFilter filter = searchOperation.getFilter(); - // Make sure the base entry exists if it's supposed to be in this backend. + // Make sure the base entry exists. A base DN this backend does not hold has no entry + // either, so it must be reported as missing rather than searched for. Entry baseEntry = entryMap.get(baseDN); - if (baseEntry == null && handlesEntry(baseDN)) + if (baseEntry == null) { DN matchedDN = serverContext.getBackendConfigManager().getParentDNInSuffix(baseDN); while (matchedDN != null) @@ -515,10 +517,7 @@ public synchronized void search(SearchOperation searchOperation) ResultCode.NO_SUCH_OBJECT, message, matchedDN, null); } - if (baseEntry != null) - { - baseEntry = baseEntry.duplicate(true); - } + baseEntry = baseEntry.duplicate(true); // If it's a base-level search, then just get that entry and return it if it // matches the filter. diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/BackendImpl.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/BackendImpl.java index 50506e33db..40b0c0f966 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/BackendImpl.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/BackendImpl.java @@ -13,7 +13,7 @@ * * Copyright 2007-2010 Sun Microsystems, Inc. * Portions Copyright 2013-2016 ForgeRock AS. - * Portions Copyright 2025 3A Systems, LLC + * Portions Copyright 2025-2026 3A Systems, LLC */ package org.opends.server.backends.pluggable; @@ -122,13 +122,29 @@ public abstract class BackendImpl extends LocalBa * @return EntryContainer where entryDN resides */ private EntryContainer accessBegin(Operation operation, DN entryDN) throws DirectoryException + { + return accessBegin(operation, entryDN, ResultCode.UNDEFINED); + } + + /** + * Begin a Backend API method that accesses the {@link EntryContainer} for entryDN + * and returns it. + * @param operation requesting the storage + * @param entryDN the target DN for the operation + * @param noEntryContainerResultCode the result code to report when this backend holds no + * entry container for entryDN + * @return EntryContainer where entryDN resides + */ + private EntryContainer accessBegin(Operation operation, DN entryDN, ResultCode noEntryContainerResultCode) + throws DirectoryException { checkRootContainerInitialized(); rootContainer.checkForEnoughResources(operation); EntryContainer ec = rootContainer.getEntryContainer(entryDN); if (ec == null) { - throw new DirectoryException(ResultCode.UNDEFINED, ERR_BACKEND_ENTRY_DOESNT_EXIST.get(entryDN, getBackendID())); + throw new DirectoryException( + noEntryContainerResultCode, ERR_BACKEND_ENTRY_DOESNT_EXIST.get(entryDN, getBackendID())); } threadTotalCount.getAndIncrement(); return ec; @@ -557,7 +573,9 @@ public void renameEntry(DN currentDN, Entry entry, ModifyDNOperation modifyDNOpe @Override public void search(SearchOperation searchOperation) throws DirectoryException, CanceledOperationException { - EntryContainer ec = accessBegin(searchOperation, searchOperation.getBaseDN()); + // a base DN held by no entry container of this backend does not exist as far as a client + // is concerned: report it as such instead of the UNDEFINED result code used internally. + EntryContainer ec = accessBegin(searchOperation, searchOperation.getBaseDN(), ResultCode.NO_SUCH_OBJECT); ec.sharedLock.lock(); diff --git a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendSearchOperation.java b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendSearchOperation.java index cec674da97..8d9db9bdec 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendSearchOperation.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/workflowelement/localbackend/LocalBackendSearchOperation.java @@ -13,7 +13,7 @@ * * Copyright 2008-2010 Sun Microsystems, Inc. * Portions Copyright 2011-2016 ForgeRock AS. - * Portions Copyright 2024-2025 3A Systems, LLC. + * Portions Copyright 2024-2026 3A Systems, LLC. */ package org.opends.server.workflowelement.localbackend; @@ -216,6 +216,10 @@ private void processSearch(AtomicBoolean executePostOpPlugins) throws CanceledOp if(!dereferencingDNs.contains(aliasedDn)) { //detect recursive search dereferencingDNs.add(aliasedDn); setBaseDN(aliasedDn); + // the alias may point into another backend: a null backend is reported + // as NO_SUCH_OBJECT by the recursive call + backend = DirectoryServer.getInstance().getServerContext() + .getBackendConfigManager().findLocalBackendForEntry(aliasedDn); try { processSearch(executePostOpPlugins); } catch (StackOverflowError error) { diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/MemoryBackendTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/MemoryBackendTestCase.java new file mode 100644 index 0000000000..a593b37fc8 --- /dev/null +++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/MemoryBackendTestCase.java @@ -0,0 +1,77 @@ +/* + * 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; + +import static org.opends.server.protocols.internal.InternalClientConnection.getRootConnection; +import static org.opends.server.protocols.internal.Requests.newSearchRequest; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.fail; + +import org.forgerock.opendj.ldap.DN; +import org.forgerock.opendj.ldap.ResultCode; +import org.forgerock.opendj.ldap.SearchScope; +import org.opends.server.TestCaseUtils; +import org.opends.server.protocols.internal.InternalSearchOperation; +import org.opends.server.protocols.internal.SearchRequest; +import org.opends.server.types.DirectoryException; +import org.opends.server.workflowelement.localbackend.LocalBackendSearchOperation; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +/** Test cases for the memory backend. */ +@SuppressWarnings("javadoc") +public class MemoryBackendTestCase extends BackendTestCase +{ + private MemoryBackend backend; + + @BeforeClass + public void startServer() throws Exception + { + TestCaseUtils.startServer(); + TestCaseUtils.initializeTestBackend(true); + backend = (MemoryBackend) TestCaseUtils.getServerContext().getBackendConfigManager() + .getLocalBackendById(TestCaseUtils.TEST_BACKEND_ID); + } + + /** A base DN this backend does not hold has no entry either. */ + @Test + public void testSearchBaseDNNotHeldByBackend() throws Exception + { + assertSearchFailsWithNoSuchObject(DN.valueOf("o=not-held-by-this-backend")); + } + + /** A base DN this backend holds, but which has never been added. */ + @Test + public void testSearchBaseDNWithoutEntry() throws Exception + { + assertSearchFailsWithNoSuchObject(DN.valueOf("ou=missing,o=test")); + } + + private void assertSearchFailsWithNoSuchObject(DN baseDN) throws Exception + { + final SearchRequest request = newSearchRequest(baseDN, SearchScope.BASE_OBJECT); + final InternalSearchOperation search = new InternalSearchOperation(getRootConnection(), -1, -1, request); + try + { + backend.search(new LocalBackendSearchOperation(search)); + fail("Searching base DN " + baseDN + " should have failed."); + } + catch (DirectoryException e) + { + assertEquals(e.getResultCode(), ResultCode.NO_SUCH_OBJECT); + } + } +} diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PluggableBackendImplTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PluggableBackendImplTestCase.java index e226c5ff91..756803015b 100644 --- a/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PluggableBackendImplTestCase.java +++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PluggableBackendImplTestCase.java @@ -12,7 +12,7 @@ * information: "Portions Copyright [year] [name of copyright owner]". * * Copyright 2015-2016 ForgeRock AS. - * Portions Copyright 2023-2025 3A Systems, LLC. + * Portions Copyright 2023-2026 3A Systems, LLC. */ package org.opends.server.backends.pluggable; @@ -645,6 +645,22 @@ public void testHasSubordinates() throws Exception "Leaf entry should not have any subordinates."); } + /** A base DN held by no entry container of this backend has no entry either. */ + @Test + public void testSearchBaseDNNotHeldByBackend() throws Exception + { + try + { + backend.search(createSearchOperation( + DN.valueOf("dc=a"), SearchScope.BASE_OBJECT, "(objectClass=*)", new ArrayList())); + fail("Searching a base DN not held by this backend should have failed."); + } + catch (DirectoryException e) + { + assertEquals(e.getResultCode(), ResultCode.NO_SUCH_OBJECT); + } + } + private List runSearch(SearchRequest request, boolean useInternalConnection) throws Exception { InternalClientConnection conn = getRootConnection(); diff --git a/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/AliasTestCase.java b/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/AliasTestCase.java index 99376f8dc6..75c2deffa8 100644 --- a/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/AliasTestCase.java +++ b/opendj-server-legacy/src/test/java/org/openidentityplatform/opendj/AliasTestCase.java @@ -11,7 +11,7 @@ * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions Copyright [year] [name of copyright owner]". * - * Copyright 2024-2025 3A Systems, LLC. + * Copyright 2024-2026 3A Systems, LLC. */ package org.openidentityplatform.opendj; @@ -23,22 +23,29 @@ import org.opends.server.DirectoryServerTestCase; import org.opends.server.TestCaseUtils; +import org.opends.server.backends.MemoryBackend; import org.opends.server.types.Entry; +import org.testng.annotations.AfterClass; import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; import java.util.HashMap; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; @Test(sequential = true) public class AliasTestCase extends DirectoryServerTestCase { + /** Backend holding a naming context other than o=test, to check aliases crossing a backend boundary. */ + static final String OTHER_BACKEND_ID = "test2"; + Connection connection; @BeforeClass public void startServer() throws Exception { TestCaseUtils.startServer(); TestCaseUtils.initializeTestBackend(true); + TestCaseUtils.initializeMemoryBackend(OTHER_BACKEND_ID, "o=test2", true); TestCaseUtils.addEntries( "dn: o=MyCompany, o=test", @@ -116,6 +123,32 @@ public void startServer() throws Exception { "objectClass: extensibleObject", "aliasedObjectName: uid=janedoe,ou=students,o=test", "uid: janedoe", + "", + + //an alias pointing into the o=test2 backend, and one pointing nowhere at all + "dn: ou=people,o=test2", + "objectClass: top", + "objectClass: organizationalUnit", + "ou: people", + "", + "dn: cn=Jane Roe,ou=people,o=test2", + "cn: Jane Roe", + "sn: Roe", + "objectclass: person", + "", + "dn: ou=external,o=test", + "objectClass: alias", + "objectClass: top", + "objectClass: extensibleObject", + "ou: external", + "aliasedObjectName: ou=people,o=test2", + "", + "dn: ou=nowhere,o=test", + "objectClass: alias", + "objectClass: top", + "objectClass: extensibleObject", + "ou: nowhere", + "aliasedObjectName: ou=people,o=missing", "" ); @@ -125,6 +158,20 @@ public void startServer() throws Exception { assertThat(connection.isValid()).isTrue(); } + @AfterClass + public void stopServer() throws Exception { + if (connection != null) { + connection.close(); + } + final MemoryBackend otherBackend = (MemoryBackend) TestCaseUtils.getServerContext() + .getBackendConfigManager().getLocalBackendById(OTHER_BACKEND_ID); + if (otherBackend != null) { + otherBackend.clearMemoryBackend(); + otherBackend.finalizeBackend(); + TestCaseUtils.getServerContext().getBackendConfigManager().deregisterLocalBackend(otherBackend); + } + } + public HashMap search(SearchScope scope,DereferenceAliasesPolicy policy) throws SearchResultReferenceIOException, LdapException { return search("ou=Area1,o=test", scope, policy); } @@ -386,6 +433,37 @@ public void test_alias_recursive_loop() throws LdapException, SearchResultRefere assertThat(res.containsKey("uid=janedoe,ou=employees,o=test")).isFalse(); } + // The alias target does not have to live in the backend holding the alias: + // the search must be handed over to the backend holding the target. + @Test + public void test_cross_backend_alias_base_always() throws LdapException, SearchResultReferenceIOException { + HashMap res = search("ou=external,o=test", SearchScope.BASE_OBJECT, DereferenceAliasesPolicy.ALWAYS); + + assertThat(res.containsKey("ou=external,o=test")).isFalse(); + assertThat(res.containsKey("ou=people,o=test2")).isTrue(); + assertThat(res.containsKey("cn=Jane Roe,ou=people,o=test2")).isFalse(); + } + + @Test + public void test_cross_backend_alias_sub_always() throws LdapException, SearchResultReferenceIOException { + HashMap res = search("ou=external,o=test", SearchScope.WHOLE_SUBTREE, DereferenceAliasesPolicy.ALWAYS); + + assertThat(res.containsKey("ou=external,o=test")).isFalse(); + assertThat(res.containsKey("ou=people,o=test2")).isTrue(); + assertThat(res.containsKey("cn=Jane Roe,ou=people,o=test2")).isTrue(); + } + + // No backend at all holds the alias target: this is a base DN that does not exist. + @Test + public void test_alias_target_without_backend() throws SearchResultReferenceIOException { + try { + search("ou=nowhere,o=test", SearchScope.BASE_OBJECT, DereferenceAliasesPolicy.ALWAYS); + fail("dereferencing an alias whose target has no backend must fail"); + } catch (LdapException e) { + assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.NO_SUCH_OBJECT); + } + } + @Test(expectedExceptions = LdapException.class) public void test_stackoverflow() throws Exception {