From 019b2c69bd5b8cf8fa9a21bd5c4479204b27cb36 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Wed, 08 Jul 2026 16:05:46 +0000
Subject: [PATCH] [#665] Fix StackOverflowError while parsing long ACI with repetitive targets (#666)

---
 opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/TargetAttr.java                     |   12 +-
 opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/Aci.java                            |   63 +++++++++---
 opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciTargets.java                     |   16 ++-
 opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/Permission.java                     |   15 ++-
 opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/AciDecodeStackOverflowTestCase.java |  162 ++++++++++++++++++++++++++++++++
 5 files changed, 234 insertions(+), 34 deletions(-)

diff --git a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/Aci.java b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/Aci.java
index 8e69671..d938a09 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/Aci.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/Aci.java
@@ -13,6 +13,7 @@
  *
  * Copyright 2008 Sun Microsystems, Inc.
  * Portions Copyright 2010-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC
  */
 package org.opends.server.authorization.dseecompat;
 
@@ -45,12 +46,26 @@
     /** Regular expression matching a word group. */
     public static final String WORD_GROUP="(\\w+)";
 
+    /**
+     * Possessive variant of {@link #WORD_GROUP}. Use inside unbounded
+     * repetition so the regex engine matches iteratively instead of
+     * recursing once per repetition (issue #665).
+     */
+    public static final String WORD_GROUP_POSSESSIVE="(\\w++)";
+
     /** Regular expression matching a word group at the start of a pattern. */
     static final String WORD_GROUP_START_PATTERN = "^" + WORD_GROUP;
 
     /** Regular expression matching a white space. */
     public static final String ZERO_OR_MORE_WHITESPACE="\\s*";
 
+    /**
+     * Possessive variant of {@link #ZERO_OR_MORE_WHITESPACE}. Use inside
+     * unbounded repetition so the regex engine matches iteratively instead of
+     * recursing once per repetition (issue #665).
+     */
+    public static final String ZERO_OR_MORE_WHITESPACE_POSSESSIVE="\\s*+";
+
     /** Regular expression matching a white space at the start of a pattern. */
     public static final String ZERO_OR_MORE_WHITESPACE_START_PATTERN =
                                              "^" + ZERO_OR_MORE_WHITESPACE ;
@@ -106,13 +121,17 @@
                     "\\+" + ZERO_OR_MORE_WHITESPACE;
 
     /** Regular expression used to do quick check of OID string. */
-    private static final String OID_NAME = "[\\d.\\*]*";
+    private static final String OID_NAME = "[\\d.\\*]*+";
 
-    /** Regular expression that matches one or more OID_NAME's separated by the "||" token. */
-    private static final String oidListRegex  =  ZERO_OR_MORE_WHITESPACE +
-            OID_NAME + ZERO_OR_MORE_WHITESPACE + "(" +
-            LOGICAL_OR + ZERO_OR_MORE_WHITESPACE + OID_NAME +
-            ZERO_OR_MORE_WHITESPACE + ")*";
+    /**
+     * Regular expression that matches one or more OID_NAME's separated by the
+     * "||" token. Possessive quantifiers keep the unbounded repetition from
+     * recursing in the regex engine (issue #665).
+     */
+    private static final String oidListRegex  =  ZERO_OR_MORE_WHITESPACE_POSSESSIVE +
+            OID_NAME + ZERO_OR_MORE_WHITESPACE_POSSESSIVE + "(" +
+            LOGICAL_OR + ZERO_OR_MORE_WHITESPACE_POSSESSIVE + OID_NAME +
+            ZERO_OR_MORE_WHITESPACE_POSSESSIVE + ")*+";
 
     /** ACI_ADD is used to set the container rights for a LDAP add operation. */
     public static final int ACI_ADD = 0x0020;
@@ -247,19 +266,27 @@
     public static Aci decode (ByteSequence byteString, DN dn)
     throws AciException {
         String input=byteString.toString();
-        //Perform a quick pattern check against the string to catch any
-        //obvious syntax errors.
-        if (!Pattern.matches(aciRegex, input)) {
-            throw new AciException(WARN_ACI_SYNTAX_GENERAL_PARSE_FAILED.get(input));
+        try {
+            //Perform a quick pattern check against the string to catch any
+            //obvious syntax errors.
+            if (!Pattern.matches(aciRegex, input)) {
+                throw new AciException(WARN_ACI_SYNTAX_GENERAL_PARSE_FAILED.get(input));
+            }
+            //Decode the body first.
+            AciBody body=AciBody.decode(input);
+            //Create a substring from the start of the string to start of
+            //the body. That should be the target.
+            String targetStr = input.substring(0, body.getMatcherStartPos());
+            //Decode that target string using the substring.
+            AciTargets targets=AciTargets.decode(targetStr, dn);
+            return new Aci(input, dn, body, targets);
+        } catch (StackOverflowError e) {
+            //Defense in depth: a pathological ACI can overflow the regex
+            //engine's recursion. StackOverflowError is an Error and would
+            //escape catch(Exception) handlers, killing the worker thread, so
+            //treat the value as an invalid ACI instead (issue #665).
+            throw new AciException(WARN_ACI_SYNTAX_GENERAL_PARSE_FAILED.get(input), e);
         }
-        //Decode the body first.
-        AciBody body=AciBody.decode(input);
-        //Create a substring from the start of the string to start of
-        //the body. That should be the target.
-        String targetStr = input.substring(0, body.getMatcherStartPos());
-        //Decode that target string using the substring.
-        AciTargets targets=AciTargets.decode(targetStr, dn);
-        return new Aci(input, dn, body, targets);
     }
 
     /**
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciTargets.java b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciTargets.java
index 79c6f29..f7e34d2 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciTargets.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/AciTargets.java
@@ -13,6 +13,7 @@
  *
  * Copyright 2008-2010 Sun Microsystems, Inc.
  * Portions Copyright 2013-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC
  */
 package org.opends.server.authorization.dseecompat;
 
@@ -63,16 +64,19 @@
 
     /** Regular expression used to match a single target rule. */
     private static final String targetRegex =
-           OPEN_PAREN +  ZERO_OR_MORE_WHITESPACE  +  WORD_GROUP +
-           ZERO_OR_MORE_WHITESPACE + "(!?=)" + ZERO_OR_MORE_WHITESPACE +
-           "\"([^\"]+)\"" + ZERO_OR_MORE_WHITESPACE + CLOSED_PAREN +
-           ZERO_OR_MORE_WHITESPACE;
+           OPEN_PAREN +  ZERO_OR_MORE_WHITESPACE_POSSESSIVE  +  WORD_GROUP_POSSESSIVE +
+           ZERO_OR_MORE_WHITESPACE_POSSESSIVE + "(!?=)" + ZERO_OR_MORE_WHITESPACE_POSSESSIVE +
+           "\"([^\"]+)\"" + ZERO_OR_MORE_WHITESPACE_POSSESSIVE + CLOSED_PAREN +
+           ZERO_OR_MORE_WHITESPACE_POSSESSIVE;
 
     /**
      * Regular expression used to match one or more target rules. The pattern is
-     * part of a general ACI verification.
+     * part of a general ACI verification. The possessive quantifiers make the
+     * regex engine match the repetition iteratively, so an ACI with thousands
+     * of repeated target rules is rejected instead of overflowing the stack
+     * (issue #665).
      */
-    static final String targetsRegex = "(" + targetRegex + ")*";
+    static final String targetsRegex = "(" + targetRegex + ")*+";
 
     /**
      * Rights that are skipped for certain target evaluations.
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/Permission.java b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/Permission.java
index 91b09c6..3e49a35 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/Permission.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/Permission.java
@@ -13,6 +13,7 @@
  *
  * Copyright 2008 Sun Microsystems, Inc.
  * Portions Copyright 2013-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC
  */
 package org.opends.server.authorization.dseecompat;
 
@@ -34,11 +35,15 @@
     /** Regular expression token representing the separator. */
     private static final String separatorToken = ",";
 
-    /** Regular expression used to match the ACI rights string. */
-    private static final String rightsRegex = ZERO_OR_MORE_WHITESPACE +
-            WORD_GROUP + ZERO_OR_MORE_WHITESPACE +
-            "(," + ZERO_OR_MORE_WHITESPACE + WORD_GROUP +
-            ZERO_OR_MORE_WHITESPACE +  ")*";
+    /**
+     * Regular expression used to match the ACI rights string. Possessive
+     * quantifiers keep the unbounded repetition from recursing in the regex
+     * engine (issue #665).
+     */
+    private static final String rightsRegex = ZERO_OR_MORE_WHITESPACE_POSSESSIVE +
+            WORD_GROUP_POSSESSIVE + ZERO_OR_MORE_WHITESPACE_POSSESSIVE +
+            "(," + ZERO_OR_MORE_WHITESPACE_POSSESSIVE + WORD_GROUP_POSSESSIVE +
+            ZERO_OR_MORE_WHITESPACE_POSSESSIVE +  ")*+";
 
     /** The access type (allow,deny) corresponding to the ACI permission value. */
     private final EnumAccessType accessType;
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/TargetAttr.java b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/TargetAttr.java
index 3c581ce..c07a6c4 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/TargetAttr.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/TargetAttr.java
@@ -13,6 +13,7 @@
  *
  * Copyright 2008 Sun Microsystems, Inc.
  * Portions Copyright 2012-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC
  */
 package org.opends.server.authorization.dseecompat;
 
@@ -45,12 +46,13 @@
 
     /**
      * Regular expression that matches one or more ATTR_NAME's separated by
-     * the "||" token.
+     * the "||" token. Possessive quantifiers keep the unbounded repetition
+     * from recursing in the regex engine (issue #665).
      */
-    private static final String attrListRegex  =  ZERO_OR_MORE_WHITESPACE +
-           ATTR_NAME + ZERO_OR_MORE_WHITESPACE + "(" +
-            LOGICAL_OR + ZERO_OR_MORE_WHITESPACE + ATTR_NAME +
-            ZERO_OR_MORE_WHITESPACE + ")*";
+    private static final String attrListRegex  =  ZERO_OR_MORE_WHITESPACE_POSSESSIVE +
+           ATTR_NAME + ZERO_OR_MORE_WHITESPACE_POSSESSIVE + "(" +
+            LOGICAL_OR + ZERO_OR_MORE_WHITESPACE_POSSESSIVE + ATTR_NAME +
+            ZERO_OR_MORE_WHITESPACE_POSSESSIVE + ")*+";
 
     /**
      * Constructor creating a class representing a targetattr keyword of an ACI.
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/AciDecodeStackOverflowTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/AciDecodeStackOverflowTestCase.java
new file mode 100644
index 0000000..535f584
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/authorization/dseecompat/AciDecodeStackOverflowTestCase.java
@@ -0,0 +1,162 @@
+/*
+ * 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.authorization.dseecompat;
+
+import java.util.regex.Pattern;
+
+import org.forgerock.opendj.ldap.ByteString;
+import org.forgerock.opendj.ldap.DN;
+import org.opends.server.DirectoryServerTestCase;
+import org.opends.server.TestCaseUtils;
+import org.opends.server.types.DirectoryException;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.assertj.core.api.Assertions.*;
+
+/**
+ * Regression tests for issue #665: an ACI with thousands of repeated target
+ * rules used to overflow the regex engine's recursion in
+ * {@code Pattern.matches(aciRegex, ...)}, throwing StackOverflowError instead
+ * of the expected AciException.
+ */
+@SuppressWarnings("javadoc")
+public class AciDecodeStackOverflowTestCase extends DirectoryServerTestCase
+{
+  /** Enough repeated target rules to overflow an unbounded regex recursion. */
+  private static final int REPEATED_TARGETS = 10000;
+  /**
+   * A small bounded stack makes any per-repetition regex recursion overflow
+   * deterministically, while the iterative (possessive) match needs far less.
+   */
+  private static final long DECODE_STACK_SIZE_BYTES = 256 * 1024;
+
+  @BeforeClass
+  public void setUp() throws Exception
+  {
+    TestCaseUtils.startFakeServer();
+  }
+
+  @AfterClass
+  public void tearDown() throws DirectoryException
+  {
+    TestCaseUtils.shutdownFakeServer();
+  }
+
+  @Test
+  public void repeatedTargetsAciIsRejectedWithoutStackOverflow() throws Exception
+  {
+    StringBuilder aci = new StringBuilder();
+    for (int i = 0; i < REPEATED_TARGETS; i++)
+    {
+      aci.append("(targetscope=\"&O\")");
+    }
+    aci.append("(version 3.0; acl \"\"; allow (read) userdn=\"ldap:///anyone\";)");
+    final String input = aci.toString();
+
+    Throwable thrown = runOnBoundedStack(new Task()
+    {
+      @Override
+      public void run() throws Exception
+      {
+        Aci.decode(ByteString.valueOfUtf8(input), DN.rootDN());
+      }
+    });
+    assertThat(thrown)
+        .as("pathological ACI must be rejected with AciException")
+        .isInstanceOf(AciException.class);
+  }
+
+  /**
+   * Checks the regex itself matches iteratively, independently of the
+   * defense-in-depth catch in Aci.decode.
+   */
+  @Test
+  public void targetsRegexMatchesIterativelyOnBoundedStack() throws Exception
+  {
+    StringBuilder targets = new StringBuilder();
+    for (int i = 0; i < REPEATED_TARGETS; i++)
+    {
+      targets.append("(targetscope=\"&O\")");
+    }
+    final String input = targets.toString();
+
+    final boolean[] matches = new boolean[1];
+    Throwable thrown = runOnBoundedStack(new Task()
+    {
+      @Override
+      public void run()
+      {
+        matches[0] = Pattern.matches(AciTargets.targetsRegex, input);
+      }
+    });
+    assertThat(thrown).as("targets regex must not recurse per repetition").isNull();
+    assertThat(matches[0]).isTrue();
+  }
+
+  @Test
+  public void validAciWithSeveralTargetsStillDecodes() throws Exception
+  {
+    String aci = "(targetattr=\"cn\")(targetscope=\"onelevel\")"
+        + "(version 3.0; acl \"test\"; allow (read,search) userdn=\"ldap:///anyone\";)";
+    assertThat(Aci.decode(ByteString.valueOfUtf8(aci), DN.rootDN()).toString()).isEqualTo(aci);
+  }
+
+  /** The possessive target regex must still reject malformed target syntax. */
+  @Test
+  public void malformedTargetStillRejected() throws Exception
+  {
+    final String aci = "(targetscope \"onelevel\")"
+        + "(version 3.0; acl \"test\"; allow (read) userdn=\"ldap:///anyone\";)";
+    assertThatThrownBy(() -> Aci.decode(ByteString.valueOfUtf8(aci), DN.rootDN()))
+        .isInstanceOf(AciException.class);
+  }
+
+  /** A runnable that may throw a checked exception (e.g. AciException). */
+  private interface Task
+  {
+    void run() throws Exception;
+  }
+
+  /**
+   * Runs the task on a thread with a small fixed stack and returns whatever
+   * it threw, including Errors, or null if it completed.
+   */
+  private Throwable runOnBoundedStack(final Task task) throws InterruptedException
+  {
+    final Throwable[] thrown = new Throwable[1];
+    Runnable guarded = new Runnable()
+    {
+      @Override
+      public void run()
+      {
+        try
+        {
+          task.run();
+        }
+        catch (Throwable t)
+        {
+          thrown[0] = t;
+        }
+      }
+    };
+    Thread worker = new Thread(null, guarded, "issue-665-bounded-stack", DECODE_STACK_SIZE_BYTES);
+    worker.start();
+    worker.join();
+    return thrown[0];
+  }
+}

--
Gitblit v1.10.0