Skip to content

Commit 9403d17

Browse files
authored
[#719] Fix NullPointerException decoding an ACI bind rule with a missing and/or operand (#720)
1 parent 3231c93 commit 9403d17

2 files changed

Lines changed: 150 additions & 3 deletions

File tree

opendj-server-legacy/src/main/java/org/opends/server/authorization/dseecompat/BindRule.java

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,9 @@ private BindRule(BindRule left, BindRule right, EnumBooleanTypes booleanType) {
142142
/**
143143
* Decode an ACI bind rule string representation.
144144
* @param input The string representation of the bind rule.
145-
* @return A BindRule class representing the bind rule.
145+
* @return A BindRule class representing the bind rule, or {@code null} if
146+
* {@code input} is null or blank. This null is the recursion base case for
147+
* an empty group; callers must reject it rather than dereference it.
146148
* @throws AciException If the string is an invalid bind rule.
147149
*/
148150
public static BindRule decode (String input) throws AciException {
@@ -153,8 +155,10 @@ public static BindRule decode (String input) throws AciException {
153155
String bindruleStr = input.trim();
154156
if (bindruleStr.isEmpty())
155157
{
156-
// A blank bind rule (e.g. "( )") must be rejected as a
157-
// syntax error rather than throwing StringIndexOutOfBoundsException.
158+
// A blank bind rule (e.g. "( )") decodes to null rather than
159+
// throwing StringIndexOutOfBoundsException on charAt(0). This null is
160+
// the recursion base case for an empty group; the caller rejects it as
161+
// a syntax error (see the bindrule_1 == null check below).
158162
return null;
159163
}
160164
char firstChar = bindruleStr.charAt(0);
@@ -202,6 +206,17 @@ else if (!inQuotes && currentChar == ')')
202206
if (numOpen > numClose) {
203207
throw new AciException(WARN_ACI_SYNTAX_BIND_RULE_MISSING_CLOSE_PAREN.get(input));
204208
}
209+
/*
210+
* An empty group such as "()" or "( )" decodes to a null bind rule.
211+
* Such a group is not a valid bind rule on its own, and using it as the
212+
* left operand of an "and"/"or" (e.g. "()or userdn=...") would build a
213+
* complex rule with a null left side that is later dereferenced during
214+
* evaluation. Reject both cases here, before the remaining-chars test,
215+
* so the message names the full offending input rather than a fragment.
216+
*/
217+
if (bindrule_1 == null) {
218+
throw new AciException(WARN_ACI_SYNTAX_INVALID_BIND_RULE_SYNTAX.get(input));
219+
}
205220
/*
206221
* If there are remaining chars => there MUST be an operand (AND / OR)
207222
* otherwise there is a syntax error
@@ -318,6 +333,16 @@ private static BindRule createBindRule(BindRule bindrule,
318333
boolean negate=determineNegation(ruleExpr);
319334
remainingBindrule=ruleExpr.toString();
320335
BindRule bindrule_2 = BindRule.decode(remainingBindrule);
336+
/*
337+
* The right operand of an "and"/"or" bind rule must exist. It is null
338+
* when the boolean operator is not followed by a bind rule, e.g. the
339+
* "or" in "userdn=\"ldap:///self\" or" has nothing to its right
340+
* (issue #719).
341+
*/
342+
if (bindrule_2 == null) {
343+
throw new AciException(
344+
WARN_ACI_SYNTAX_INVALID_BIND_RULE_SYNTAX.get(remainingBindruleStr));
345+
}
321346
bindrule_2.setNegate(negate);
322347
return new BindRule(bindrule, bindrule_2, operand);
323348
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/*
2+
* The contents of this file are subject to the terms of the Common Development and
3+
* Distribution License (the License). You may not use this file except in compliance with the
4+
* License.
5+
*
6+
* You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
7+
* specific language governing permission and limitations under the License.
8+
*
9+
* When distributing Covered Software, include this CDDL Header Notice in each file and include
10+
* the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
11+
* Header, with the fields enclosed by brackets [] replaced by your own identifying
12+
* information: "Portions Copyright [year] [name of copyright owner]".
13+
*
14+
* Copyright 2026 3A Systems, LLC.
15+
*/
16+
package org.opends.server.authorization.dseecompat;
17+
18+
import static org.assertj.core.api.Assertions.*;
19+
20+
import org.forgerock.opendj.ldap.ByteString;
21+
import org.forgerock.opendj.ldap.DN;
22+
import org.opends.server.DirectoryServerTestCase;
23+
import org.opends.server.TestCaseUtils;
24+
import org.opends.server.types.DirectoryException;
25+
import org.testng.annotations.AfterClass;
26+
import org.testng.annotations.BeforeClass;
27+
import org.testng.annotations.DataProvider;
28+
import org.testng.annotations.Test;
29+
30+
/**
31+
* Verifies that {@link BindRule#decode(String)} rejects a boolean ("and"/"or")
32+
* bind rule that is missing one of its operands, instead of throwing a
33+
* {@link NullPointerException}. An empty group such as "()" decodes to a null
34+
* bind rule; before this fix, using such a group as the left side, or leaving
35+
* the right side of the boolean empty (e.g. "(()or)"), dereferenced that null
36+
* while parsing and failed with an NPE rather than a diagnosable
37+
* {@link AciException} (issue #719).
38+
*/
39+
@SuppressWarnings("javadoc")
40+
public class BindRuleOperandTest extends DirectoryServerTestCase
41+
{
42+
@BeforeClass
43+
public void setUp() throws Exception
44+
{
45+
TestCaseUtils.startFakeServer();
46+
}
47+
48+
@AfterClass
49+
public void tearDown() throws DirectoryException
50+
{
51+
TestCaseUtils.shutdownFakeServer();
52+
}
53+
54+
@DataProvider(name = "bindRulesWithMissingOperand")
55+
public Object[][] bindRulesWithMissingOperand()
56+
{
57+
return new Object[][] {
58+
// the exact bind rule from issue #719: "or" with no right operand and an
59+
// empty left group
60+
{ "(()or)" },
61+
// empty right operand after a valid left operand
62+
{ "(userdn=\"ldap:///self\" or)" },
63+
{ "(userdn=\"ldap:///self\" and)" },
64+
// empty left operand before a valid right operand
65+
{ "()or userdn=\"ldap:///self\"" },
66+
// an empty group is not a bind rule on its own
67+
{ "()" },
68+
// a nested empty group is still empty
69+
{ "(())" },
70+
// a whitespace-only group is empty once trimmed
71+
{ "( )" },
72+
// empty right *group*: reaches the decode-level guard from createBindRule
73+
{ "(userdn=\"ldap:///self\" or ())" },
74+
// "not" applied to an empty group
75+
{ "not ()" },
76+
};
77+
}
78+
79+
@Test(dataProvider = "bindRulesWithMissingOperand")
80+
public void rejectsMissingOperand(String bindRule)
81+
{
82+
assertThatThrownBy(() -> BindRule.decode(bindRule)).isInstanceOf(AciException.class);
83+
}
84+
85+
@DataProvider(name = "validBindRules")
86+
public Object[][] validBindRules()
87+
{
88+
return new Object[][] {
89+
// a valid rule inside a nested group must still decode: guards must not
90+
// start over-rejecting non-empty groups
91+
{ "((userdn=\"ldap:///self\"))" },
92+
// a valid complex rule with both operands present
93+
{ "(userdn=\"ldap:///self\" or userdn=\"ldap:///anyone\")" },
94+
};
95+
}
96+
97+
@Test(dataProvider = "validBindRules")
98+
public void acceptsValidBindRule(String bindRule) throws Exception
99+
{
100+
assertThat(BindRule.decode(bindRule)).isNotNull();
101+
}
102+
103+
@DataProvider(name = "acisWithMissingOperand")
104+
public Object[][] acisWithMissingOperand()
105+
{
106+
return new Object[][] {
107+
// the full ACI from issue #719
108+
{ "(version 3.0; acl \"ac\"; allow (search)(()or) (userdn=\"ldap:///self\"); )" },
109+
// missing *left* operand: previously accepted and stored with left == null,
110+
// then NPEd during evaluation. Must now be rejected at decode time.
111+
{ "(version 3.0; acl \"ac\"; allow (search) ()or userdn=\"ldap:///self\"; )" },
112+
};
113+
}
114+
115+
/** Reproduces missing-operand ACIs from issue #719 through {@link Aci#decode}. */
116+
@Test(dataProvider = "acisWithMissingOperand")
117+
public void decodeOfAciWithMissingOperandThrowsAciException(String aci)
118+
{
119+
assertThatThrownBy(() -> Aci.decode(ByteString.valueOfUtf8(aci), DN.rootDN()))
120+
.isInstanceOf(AciException.class);
121+
}
122+
}

0 commit comments

Comments
 (0)