From 5c326850f1ab945cfca7ac9c5aaf77d1052c6bed Mon Sep 17 00:00:00 2001
From: Valera V Harseko <vharseko@3a-systems.ru>
Date: Fri, 17 Jul 2026 11:19:46 +0000
Subject: [PATCH] GHSA-p279-2cqp-84jg SASL PLAIN authzid bypassing the proxy ACI scope check
---
opendj-server-legacy/src/test/java/org/opends/server/types/PrivilegeTestCase.java | 168 +++++++++++++++++++++++++++++++++
opendj-server-legacy/src/main/java/org/opends/server/extensions/SASLContext.java | 56 ++++++++---
opendj-server-legacy/src/main/java/org/opends/server/extensions/PlainSASLMechanismHandler.java | 42 ++++++++
3 files changed, 250 insertions(+), 16 deletions(-)
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/extensions/PlainSASLMechanismHandler.java b/opendj-server-legacy/src/main/java/org/opends/server/extensions/PlainSASLMechanismHandler.java
index eff15b6..9a9b478 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/extensions/PlainSASLMechanismHandler.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/extensions/PlainSASLMechanismHandler.java
@@ -13,6 +13,7 @@
*
* Copyright 2006-2009 Sun Microsystems, Inc.
* Portions Copyright 2011-2016 ForgeRock AS.
+ * Portions Copyrighted 2026 3A Systems, LLC.
*/
package org.opends.server.extensions;
@@ -330,6 +331,11 @@
return;
}
}
+
+ if (! checkProxyAccess(bindOperation, userEntry, authZEntry))
+ {
+ return;
+ }
}
}
else
@@ -391,6 +397,11 @@
bindOperation.setAuthFailureReason(message);
return;
}
+
+ if (! checkProxyAccess(bindOperation, userEntry, authZEntry))
+ {
+ return;
+ }
}
}
}
@@ -444,6 +455,37 @@
return;
}
+ /**
+ * Checks whether the authenticated user is allowed by the access control
+ * subsystem to assume the given authorization identity (i.e. holds the
+ * "proxy" access control right for the target entry), and if not sets the
+ * bind failure result. This delegates to {@link SASLContext#hasProxyAccess}
+ * so that SASL PLAIN enforces the same ACI scope check as the proxied
+ * authorization controls and the DIGEST-MD5 / GSSAPI {@code authzid}, in
+ * addition to the {@code PROXIED_AUTH} privilege check. The denial uses the
+ * same {@code INVALID_CREDENTIALS} result and message as DIGEST-MD5 / GSSAPI
+ * so a client cannot distinguish a missing privilege from a missing proxy
+ * grant.
+ *
+ * @param bindOperation The bind operation being processed.
+ * @param authEntry The authenticated user entry (the proxy user).
+ * @param authZEntry The entry to be assumed, or {@code null} for the
+ * anonymous / root authorization identity.
+ * @return {@code true} if the access control configuration permits the
+ * authenticated user to assume the authorization identity.
+ */
+ private boolean checkProxyAccess(BindOperation bindOperation, Entry authEntry, Entry authZEntry)
+ {
+ if (! SASLContext.hasProxyAccess(authEntry, authZEntry, bindOperation))
+ {
+ bindOperation.setResultCode(ResultCode.INVALID_CREDENTIALS);
+ bindOperation.setAuthFailureReason(
+ ERR_SASL_AUTHZID_INSUFFICIENT_ACCESS.get(authEntry.getName()));
+ return false;
+ }
+ return true;
+ }
+
@Override
public boolean isPasswordBased(String mechanism)
{
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/extensions/SASLContext.java b/opendj-server-legacy/src/main/java/org/opends/server/extensions/SASLContext.java
index 7b24cd5..6f62e51 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/extensions/SASLContext.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/extensions/SASLContext.java
@@ -13,6 +13,7 @@
*
* Copyright 2008-2009 Sun Microsystems, Inc.
* Portions Copyright 2011-2016 ForgeRock AS.
+ * Portions Copyrighted 2026 3A Systems, LLC.
*/
package org.opends.server.extensions;
@@ -58,6 +59,7 @@
import org.opends.server.types.AuthenticationInfo;
import org.opends.server.types.DirectoryException;
import org.opends.server.types.Entry;
+import org.opends.server.types.Operation;
import org.opends.server.types.Privilege;
/**
@@ -927,30 +929,54 @@
*/
private boolean hasPermission(final AuthenticationInfo authInfo)
{
- boolean ret = true;
- Entry e = authzEntry;
-
- // If the authz entry is null, use the entry associated with the NULL DN.
- if (e == null)
+ if (!hasProxyAccess(authInfo.getAuthenticationEntry(), authzEntry, bindOp))
{
+ setCallbackMsg(ERR_SASL_AUTHZID_INSUFFICIENT_ACCESS.get(authEntry.getName()));
+ return false;
+ }
+ return true;
+ }
+
+
+
+ /**
+ * Determines whether the access control configuration permits the
+ * authenticated user to assume the given authorization identity, i.e. holds
+ * the "proxy" access control right for the target entry. A {@code null}
+ * authorization entry maps to the entry associated with the NULL DN (the
+ * anonymous / root authorization identity). This is the shared SASL
+ * proxy-scope check used by DIGEST-MD5 / GSSAPI and by SASL PLAIN.
+ *
+ * @param authEntry
+ * The authenticated user entry (the proxy user).
+ * @param authzEntry
+ * The entry to be assumed, or {@code null} for the anonymous / root
+ * authorization identity.
+ * @param operation
+ * The operation being processed.
+ * @return {@code true} if the authenticated user may assume the authorization
+ * identity.
+ */
+ static boolean hasProxyAccess(final Entry authEntry, final Entry authzEntry,
+ final Operation operation)
+ {
+ Entry proxiedEntry = authzEntry;
+ if (proxiedEntry == null)
+ {
+ // A null authorization entry maps to the entry associated with the NULL DN.
try
{
- e = DirectoryServer.getEntry(DN.rootDN());
+ proxiedEntry = DirectoryServer.getEntry(DN.rootDN());
}
- catch (final DirectoryException ex)
+ catch (final DirectoryException e)
{
+ logger.traceException(e);
return false;
}
}
- if (!AccessControlConfigManager.getInstance().getAccessControlHandler()
- .mayProxy(authInfo.getAuthenticationEntry(), e, bindOp))
- {
- setCallbackMsg(ERR_SASL_AUTHZID_INSUFFICIENT_ACCESS.get(authEntry.getName()));
- ret = false;
- }
-
- return ret;
+ return AccessControlConfigManager.getInstance().getAccessControlHandler()
+ .mayProxy(authEntry, proxiedEntry, operation);
}
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/types/PrivilegeTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/types/PrivilegeTestCase.java
index ca9e0dd..5c6b976 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/types/PrivilegeTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/types/PrivilegeTestCase.java
@@ -218,7 +218,31 @@
"givenName: PWReset",
"sn: Target",
"uid: pwreset.target",
- "userPassword: password");
+ "userPassword: password",
+ "",
+ "dn: cn=Proxy Only User,o=test",
+ "objectClass: top",
+ "objectClass: person",
+ "objectClass: organizationalPerson",
+ "objectClass: inetOrgPerson",
+ "cn: Proxy Only User",
+ "givenName: Proxy",
+ "sn: User",
+ "uid: proxy.only",
+ "userPassword: password",
+ "ds-privilege-name: proxied-auth",
+ "",
+ "dn: cn=Proxy ACI User,o=test",
+ "objectClass: top",
+ "objectClass: person",
+ "objectClass: organizationalPerson",
+ "objectClass: inetOrgPerson",
+ "cn: Proxy ACI User",
+ "givenName: Proxy",
+ "sn: ACI User",
+ "uid: proxy.aci",
+ "userPassword: password",
+ "ds-privilege-name: proxied-auth");
TestCaseUtils.applyModifications(false,
"dn: o=test",
@@ -230,6 +254,8 @@
"userdn=\"ldap:///cn=Unprivileged Root,cn=Root DNs,cn=config\";)",
"aci: (version 3.0; acl \"Privileged User\"; allow (proxy) " +
"userdn=\"ldap:///cn=Privileged User,o=test\";)",
+ "aci: (version 3.0; acl \"Proxy ACI User\"; allow (proxy) " +
+ "userdn=\"ldap:///cn=Proxy ACI User,o=test\";)",
"aci: (targetattr=\"*\")(version 3.0; acl \"PWReset Target\"; " +
"allow (all) userdn=\"ldap:///cn=PWReset Target,o=test\";)");
@@ -2146,6 +2172,146 @@
/**
+ * Regression test for GHSA-p279-2cqp-84jg: an authentication identity that
+ * holds the PROXIED_AUTH privilege but is not granted the "proxy" access
+ * control right for the target must be denied when specifying an alternate
+ * authorization ID through SASL PLAIN using the "dn:" syntax, matching the
+ * behaviour of the proxied authorization control and DIGEST-MD5.
+ *
+ * @throws Exception If an unexpected problem occurs.
+ */
+ @Test
+ public void testPLAINDifferentAuthzIDNoProxyAciDeniedDNColon()
+ throws Exception
+ {
+ // Control: the proxy user can authenticate and act as itself, proving its
+ // entry and password are valid so the denial below is an authorization
+ // failure and not a setup or authentication problem.
+ String[] selfArgs =
+ {
+ "--noPropertiesFile",
+ "-h", "127.0.0.1",
+ "-p", String.valueOf(TestCaseUtils.getServerLdapPort()),
+ "-o", "mech=PLAIN",
+ "-o", "authid=dn:cn=Proxy Only User,o=test",
+ "-o", "authzid=dn:cn=Proxy Only User,o=test",
+ "-w", "password",
+ "-b", "",
+ "-s", "base",
+ "(objectClass=*)"
+ };
+
+ assertEquals(runSearchWithSystemErr(selfArgs), 0);
+
+ // Holding PROXIED_AUTH but lacking a "proxy" ACI over the target must be
+ // denied with INVALID_CREDENTIALS, matching DIGEST-MD5 / GSSAPI.
+ String[] args =
+ {
+ "--noPropertiesFile",
+ "-h", "127.0.0.1",
+ "-p", String.valueOf(TestCaseUtils.getServerLdapPort()),
+ "-o", "mech=PLAIN",
+ "-o", "authid=dn:cn=Proxy Only User,o=test",
+ "-o", "authzid=dn:cn=Unprivileged User,o=test",
+ "-w", "password",
+ "-b", "o=test",
+ "-s", "base",
+ "(objectClass=*)"
+ };
+
+ assertEquals(runSearch(args), INVALID_CREDENTIALS.intValue());
+ }
+
+
+
+ /**
+ * Regression test for GHSA-p279-2cqp-84jg: an authentication identity that
+ * holds the PROXIED_AUTH privilege but is not granted the "proxy" access
+ * control right for the target must be denied when specifying an alternate
+ * authorization ID through SASL PLAIN using the "u:" syntax, matching the
+ * behaviour of the proxied authorization control and DIGEST-MD5.
+ *
+ * @throws Exception If an unexpected problem occurs.
+ */
+ @Test
+ public void testPLAINDifferentAuthzIDNoProxyAciDeniedUColon()
+ throws Exception
+ {
+ // Control: the proxy user can authenticate and act as itself, proving its
+ // entry and password are valid so the denial below is an authorization
+ // failure and not a setup or authentication problem.
+ String[] selfArgs =
+ {
+ "--noPropertiesFile",
+ "-h", "127.0.0.1",
+ "-p", String.valueOf(TestCaseUtils.getServerLdapPort()),
+ "-o", "mech=PLAIN",
+ "-o", "authid=u:proxy.only",
+ "-o", "authzid=u:proxy.only",
+ "-w", "password",
+ "-b", "",
+ "-s", "base",
+ "(objectClass=*)"
+ };
+
+ assertEquals(runSearchWithSystemErr(selfArgs), 0);
+
+ // Holding PROXIED_AUTH but lacking a "proxy" ACI over the target must be
+ // denied with INVALID_CREDENTIALS, matching DIGEST-MD5 / GSSAPI.
+ String[] args =
+ {
+ "--noPropertiesFile",
+ "-h", "127.0.0.1",
+ "-p", String.valueOf(TestCaseUtils.getServerLdapPort()),
+ "-o", "mech=PLAIN",
+ "-o", "authid=u:proxy.only",
+ "-o", "authzid=u:unprivileged.user",
+ "-w", "password",
+ "-b", "o=test",
+ "-s", "base",
+ "(objectClass=*)"
+ };
+
+ assertEquals(runSearch(args), INVALID_CREDENTIALS.intValue());
+ }
+
+
+
+ /**
+ * Regression test for GHSA-p279-2cqp-84jg: an authentication identity that
+ * holds the PROXIED_AUTH privilege AND is granted the "proxy" access control
+ * right for the target through an ACI, but does NOT hold the bypass-acl
+ * privilege, must be allowed to assume an alternate authorization ID through
+ * SASL PLAIN. This confirms the added mayProxy check does not over-deny
+ * legitimate ACI-granted proxying (the bypass-acl proxy users exercised by
+ * the other success tests short-circuit the ACI evaluation).
+ *
+ * @throws Exception If an unexpected problem occurs.
+ */
+ @Test
+ public void testPLAINDifferentAuthzIDWithProxyAciSucceedsDNColon()
+ throws Exception
+ {
+ String[] args =
+ {
+ "--noPropertiesFile",
+ "-h", "127.0.0.1",
+ "-p", String.valueOf(TestCaseUtils.getServerLdapPort()),
+ "-o", "mech=PLAIN",
+ "-o", "authid=dn:cn=Proxy ACI User,o=test",
+ "-o", "authzid=dn:cn=Unprivileged User,o=test",
+ "-w", "password",
+ "-b", "o=test",
+ "-s", "base",
+ "(objectClass=*)"
+ };
+
+ assertEquals(runSearchWithSystemErr(args), 0);
+ }
+
+
+
+ /**
* Tests the ability to disable a privilege so that an operation which will
* fail for a user without an appropriate privilege will succeed if that
* privilege is disabled.
--
Gitblit v1.10.0