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