This is an automated email from the ASF dual-hosted git repository.

jongyoul pushed a commit to branch branch-0.12
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/branch-0.12 by this push:
     new c560f3b819 [ZEPPELIN-6599] Stop treating the user list search text as 
LDAP filter and regex syntax
c560f3b819 is described below

commit c560f3b8190329e64abdc4ce1bfa43d4ffbb0682
Author: κΉ€μ˜ˆλ‚˜ <[email protected]>
AuthorDate: Thu Aug 6 17:12:05 2026 +0900

    [ZEPPELIN-6599] Stop treating the user list search text as LDAP filter and 
regex syntax
    
    ### What is this PR for?
    
    `GET /api/security/userlist/{searchText}` interprets the client-supplied 
search text as syntax in two places. This PR fixes both.
    
    **1. LDAP search filters**
    
    `ShiroAuthenticationService` builds the LDAP search filter by string 
concatenation, in `getUserList(DefaultLdapRealm, String, int)` and 
`getUserList(LdapRealm, String, int)`. The filter metacharacters `(`, `)`, `*`, 
`\` and NUL are therefore interpreted as filter syntax instead of literal text.
    
    Both sites now go through `LdapFilterEncoder.escapeFilterValue`, the RFC 
4515 escape utility that `ActiveDirectoryGroupRealm` already uses. The filter 
building is extracted into two small helpers so the rendered filter can be 
asserted directly in unit tests, following the `expandFilterTemplate` approach 
already used in `LdapRealm`.
    
    The wildcards that Zeppelin adds around the search text stay outside the 
escaped value, so substring matching behaves exactly as before. Only an 
asterisk typed by the user becomes a literal character. The configured 
attribute name and object class are escaped as well for defense in depth, while 
the raw attribute name is still used for `setReturningAttributes`.
    
    The remaining realm branches of `getMatchedUsers` are already safe: 
`ActiveDirectoryGroupRealm` escapes the value in `searchForUserName`, and the 
`JdbcRealm` branch uses a `PreparedStatement` with a validated identifier.
    
    **2. Regular expression in the sorting comparator**
    
    `SecurityRestApi` sorted the matched users with `o1.matches(searchText + 
"(.*)")`, which compiles the search text as a regular expression. A search text 
of `*` therefore fails with `PatternSyntaxException: Dangling meta character 
'*'` and the endpoint responds with HTTP 500. That comparator only wants to 
list the users whose name starts with the search text first, which `startsWith` 
does without compiling anything. The replacement is a consistent comparator as 
well, and the alphabetic [...]
    
    ### What type of PR is it?
    
    Improvement
    
    ### Todos
    
    * [x] Escape the search text at both LDAP filter building sites
    * [x] Unit tests for the rendered filters (16)
    * [x] Sort the user list without compiling the search text as a regular 
expression
    * [x] Endpoint test for search texts containing regex metacharacters
    
    ### What is the Jira issue?
    
    https://issues.apache.org/jira/browse/ZEPPELIN-6599
    
    ### How should this be tested?
    
    ```
    ./mvnw test -pl zeppelin-server 
-Dtest=ShiroAuthenticationServiceFilterInjectionTest,SecurityRestApiTest
    ```
    
    `SecurityRestApiTest.testGetUserListWithRegexMetacharacters` returns HTTP 
500 without the comparator change and HTTP 200 with it. The existing LDAP realm 
and Shiro service tests keep passing.
    
    The LDAP part was also verified manually against an in-memory LDAP server. 
With the search text `*)(cn=Bob`, the directory server received 
`(&(objectclass=person)(uid=*)(cn=Bob*))` before this change, so the `uid` 
condition was neutralized and an extra condition was appended. After this 
change the same input arrives as 
`(&(objectclass=person)(uid=*\2a\29\28cn=Bob*))`, while a normal search text 
produces exactly the same filter and the same results as before.
    
    Worth noting that the entries matched by an injected filter are not 
disclosed in the REST response today, because `SecurityRestApi` filters the 
returned list once more with `containsIgnoreCase(user, searchText)`. So the 
LDAP part is hardening of the filter building rather than a fix for an 
information leak.
    
    ### Screenshots (if appropriate)
    
    Not applicable.
    
    ### Questions
    
    * Does the licenses files need update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? No
    
    Closes #5384 from kimyenac/ZEPPELIN-6599.
    
    Signed-off-by: Jongyoul Lee <[email protected]>
    (cherry picked from commit b90e68634b0f2b6e389a84925ea0657457253a9f)
    Signed-off-by: Jongyoul Lee <[email protected]>
---
 .../org/apache/zeppelin/rest/SecurityRestApi.java  |  15 +-
 .../service/ShiroAuthenticationService.java        |  34 ++++-
 .../apache/zeppelin/rest/SecurityRestApiTest.java  |  17 +++
 ...roAuthenticationServiceFilterInjectionTest.java | 170 +++++++++++++++++++++
 4 files changed, 218 insertions(+), 18 deletions(-)

diff --git 
a/zeppelin-server/src/main/java/org/apache/zeppelin/rest/SecurityRestApi.java 
b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/SecurityRestApi.java
index 89bc317734..5eec6e7713 100644
--- 
a/zeppelin-server/src/main/java/org/apache/zeppelin/rest/SecurityRestApi.java
+++ 
b/zeppelin-server/src/main/java/org/apache/zeppelin/rest/SecurityRestApi.java
@@ -18,6 +18,7 @@ package org.apache.zeppelin.rest;
 
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -102,16 +103,10 @@ public class SecurityRestApi extends AbstractRestApi {
     List<String> autoSuggestRoleList = new ArrayList<>();
     Collections.sort(usersList);
     Collections.sort(rolesList);
-    Collections.sort(
-        usersList,
-        (o1, o2) -> {
-          if (o1.matches(searchText + "(.*)") && o2.matches(searchText + 
"(.*)")) {
-            return 0;
-          } else if (o1.matches(searchText + "(.*)")) {
-            return -1;
-          }
-          return 0;
-        });
+    // List the users whose name starts with the search text first, keeping 
the alphabetical order
+    // within each group. The search text comes from the client, so it must 
not be compiled as a
+    // regular expression here.
+    usersList.sort(Comparator.comparing((String user) -> 
!user.startsWith(searchText)));
     int maxLength = 0;
     for (String user : usersList) {
       if (StringUtils.containsIgnoreCase(user, searchText)) {
diff --git 
a/zeppelin-server/src/main/java/org/apache/zeppelin/service/ShiroAuthenticationService.java
 
b/zeppelin-server/src/main/java/org/apache/zeppelin/service/ShiroAuthenticationService.java
index 21219c6e2e..3797624a01 100644
--- 
a/zeppelin-server/src/main/java/org/apache/zeppelin/service/ShiroAuthenticationService.java
+++ 
b/zeppelin-server/src/main/java/org/apache/zeppelin/service/ShiroAuthenticationService.java
@@ -53,6 +53,7 @@ import org.apache.shiro.util.JdbcUtils;
 import org.apache.shiro.util.ThreadContext;
 import org.apache.zeppelin.conf.ZeppelinConfiguration;
 import org.apache.zeppelin.realm.ActiveDirectoryGroupRealm;
+import org.apache.zeppelin.realm.LdapFilterEncoder;
 import org.apache.zeppelin.realm.LdapRealm;
 import org.apache.zeppelin.realm.jwt.KnoxJwtRealm;
 import org.slf4j.Logger;
@@ -315,7 +316,7 @@ public class ShiroAuthenticationService implements 
AuthenticationService {
       String[] attrIDs = {userDnPrefix};
       constraints.setReturningAttributes(attrIDs);
       NamingEnumeration<SearchResult> result =
-          ctx.search(userDnSuffix, "(" + userDnPrefix + "=*" + searchText + 
"*)", constraints);
+          ctx.search(userDnSuffix, buildUserSearchFilter(userDnPrefix, 
searchText), constraints);
       while (result.hasMore()) {
         Attributes attrs = result.next().getAttributes();
         if (attrs.get(userDnPrefix) != null) {
@@ -330,6 +331,17 @@ public class ShiroAuthenticationService implements 
AuthenticationService {
     return userList;
   }
 
+  /**
+   * Builds the user search filter for {@link DefaultLdapRealm}. The attribute 
name and the search
+   * text are escaped per RFC 4515; the wildcards Zeppelin adds around the 
search text stay outside
+   * the escaped value so that substring matching keeps working.
+   */
+  static String buildUserSearchFilter(String userDnPrefix, String searchText) {
+    return String.format("(%s=*%s*)",
+        LdapFilterEncoder.escapeFilterValue(userDnPrefix),
+        LdapFilterEncoder.escapeFilterValue(searchText));
+  }
+
   /** Function to extract users from Zeppelin LdapRealm. */
   private List<String> getUserList(LdapRealm r, String searchText, int 
numUsersToFetch) {
     List<String> userList = new ArrayList<>();
@@ -348,13 +360,7 @@ public class ShiroAuthenticationService implements 
AuthenticationService {
       NamingEnumeration<SearchResult> result =
           ctx.search(
               userSearchRealm,
-              "(&(objectclass="
-                  + userObjectClass
-                  + ")("
-                  + userAttribute
-                  + "=*"
-                  + searchText
-                  + "*))",
+              buildUserSearchFilterWithObjectClass(userObjectClass, 
userAttribute, searchText),
               constraints);
       while (result.hasMore()) {
         Attributes attrs = result.next().getAttributes();
@@ -377,6 +383,18 @@ public class ShiroAuthenticationService implements 
AuthenticationService {
     return userList;
   }
 
+  /**
+   * Builds the user search filter for Zeppelin {@link LdapRealm}. Follows the 
same escaping rules
+   * as {@link #buildUserSearchFilter(String, String)}, with the user object 
class escaped as well.
+   */
+  static String buildUserSearchFilterWithObjectClass(String userObjectClass, 
String userAttribute,
+      String searchText) {
+    return String.format("(&(objectclass=%s)(%s=*%s*))",
+        LdapFilterEncoder.escapeFilterValue(userObjectClass),
+        LdapFilterEncoder.escapeFilterValue(userAttribute),
+        LdapFilterEncoder.escapeFilterValue(searchText));
+  }
+
   /**
    * * Get user roles from shiro.ini for Zeppelin LdapRealm.
    *
diff --git 
a/zeppelin-server/src/test/java/org/apache/zeppelin/rest/SecurityRestApiTest.java
 
b/zeppelin-server/src/test/java/org/apache/zeppelin/rest/SecurityRestApiTest.java
index b7e01b1fec..25912b063f 100644
--- 
a/zeppelin-server/src/test/java/org/apache/zeppelin/rest/SecurityRestApiTest.java
+++ 
b/zeppelin-server/src/test/java/org/apache/zeppelin/rest/SecurityRestApiTest.java
@@ -91,6 +91,23 @@ class SecurityRestApiTest extends AbstractTestRestApi {
     notUser.close();
   }
 
+  @Test
+  void testGetUserListWithRegexMetacharacters() throws IOException {
+    // The search text is not a regular expression. Metacharacters must not 
break the endpoint.
+    for (String searchText : new String[] {"%2A", "%28", "%2B"}) {
+      CloseableHttpResponse get = httpGet("/security/userlist/" + searchText, 
"admin", "password1");
+      assertThat("Status code for search text " + searchText,
+          get.getStatusLine().getStatusCode(), CoreMatchers.equalTo(200));
+      Map<String, Object> resp = gson.fromJson(
+          EntityUtils.toString(get.getEntity(), StandardCharsets.UTF_8),
+          new TypeToken<Map<String, Object>>(){}.getType());
+      List<String> userList = (List) ((Map) resp.get("body")).get("users");
+      assertThat("Search result size for search text " + searchText, 
userList.size(),
+          CoreMatchers.equalTo(0));
+      get.close();
+    }
+  }
+
   @Test
   void testRolesEscaped() throws IOException {
     CloseableHttpResponse get = httpGet("/security/ticket", "admin", 
"password1");
diff --git 
a/zeppelin-server/src/test/java/org/apache/zeppelin/service/ShiroAuthenticationServiceFilterInjectionTest.java
 
b/zeppelin-server/src/test/java/org/apache/zeppelin/service/ShiroAuthenticationServiceFilterInjectionTest.java
new file mode 100644
index 0000000000..14dca43b90
--- /dev/null
+++ 
b/zeppelin-server/src/test/java/org/apache/zeppelin/service/ShiroAuthenticationServiceFilterInjectionTest.java
@@ -0,0 +1,170 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.zeppelin.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Tests verifying that the search text supplied to
+ * {@code GET /api/security/userlist/{searchText}} cannot inject LDAP filter 
metacharacters into
+ * the filters that {@link ShiroAuthenticationService} builds for the LDAP 
realms. The rendered
+ * filter must never contain unescaped {@code (}, {@code )} or {@code *} 
characters that
+ * originated in the search text.
+ */
+class ShiroAuthenticationServiceFilterInjectionTest {
+
+  private static final String USER_ATTRIBUTE = "uid";
+  private static final String USER_OBJECT_CLASS = "person";
+
+  // "(uid=*%s*)" contributes 1 '(', 1 ')' and the 2 wildcards Zeppelin adds 
itself.
+  private static final int DEFAULT_LDAP_OPEN_PARENS = 1;
+  private static final int DEFAULT_LDAP_CLOSE_PARENS = 1;
+  private static final int DEFAULT_LDAP_ASTERISKS = 2;
+
+  // "(&(objectclass=person)(uid=*%s*))" contributes 3 '(', 3 ')' and the same 
2 wildcards.
+  private static final int LDAP_REALM_OPEN_PARENS = 3;
+  private static final int LDAP_REALM_CLOSE_PARENS = 3;
+  private static final int LDAP_REALM_ASTERISKS = 2;
+
+  static Stream<String> injectionPayloads() {
+    return Stream.of(
+        ")(uid=*",
+        "admin)(|(uid=*",
+        "*",
+        "admin)(cn=a*",
+        ")(mail=*@corp.com",
+        "alice)(userPassword=*",
+        "alice\\",
+        "alice\\2a",
+        "alice\\29\\28uid=\\2a",
+        "\\",
+        "\0");
+  }
+
+  @ParameterizedTest
+  @MethodSource("injectionPayloads")
+  void defaultLdapRealmFilterNeutralizesPayload(String payload) {
+    String rendered = 
ShiroAuthenticationService.buildUserSearchFilter(USER_ATTRIBUTE, payload);
+
+    assertMetacharacterCounts(rendered, payload,
+        DEFAULT_LDAP_OPEN_PARENS, DEFAULT_LDAP_CLOSE_PARENS, 
DEFAULT_LDAP_ASTERISKS);
+  }
+
+  @ParameterizedTest
+  @MethodSource("injectionPayloads")
+  void ldapRealmFilterNeutralizesPayload(String payload) {
+    String rendered = 
ShiroAuthenticationService.buildUserSearchFilterWithObjectClass(
+        USER_OBJECT_CLASS, USER_ATTRIBUTE, payload);
+
+    assertMetacharacterCounts(rendered, payload,
+        LDAP_REALM_OPEN_PARENS, LDAP_REALM_CLOSE_PARENS, LDAP_REALM_ASTERISKS);
+  }
+
+  @Test
+  void normalSearchTextKeepsSubstringMatching() {
+    assertEquals("(uid=*alice*)",
+        ShiroAuthenticationService.buildUserSearchFilter(USER_ATTRIBUTE, 
"alice"));
+    assertEquals("(&(objectclass=person)(uid=*alice*))",
+        ShiroAuthenticationService.buildUserSearchFilterWithObjectClass(
+            USER_OBJECT_CLASS, USER_ATTRIBUTE, "alice"));
+  }
+
+  @Test
+  void asteriskInSearchTextBecomesLiteral() {
+    // The wildcards Zeppelin adds stay wildcards, the one typed by the user 
does not.
+    assertEquals("(uid=*a\\2ab*)",
+        ShiroAuthenticationService.buildUserSearchFilter(USER_ATTRIBUTE, 
"a*b"));
+    assertEquals("(&(objectclass=person)(uid=*a\\2ab*))",
+        ShiroAuthenticationService.buildUserSearchFilterWithObjectClass(
+            USER_OBJECT_CLASS, USER_ATTRIBUTE, "a*b"));
+  }
+
+  @Test
+  void emptySearchTextKeepsExistingBehaviour() {
+    assertEquals("(uid=**)",
+        ShiroAuthenticationService.buildUserSearchFilter(USER_ATTRIBUTE, ""));
+    assertEquals("(&(objectclass=person)(uid=**))",
+        ShiroAuthenticationService.buildUserSearchFilterWithObjectClass(
+            USER_OBJECT_CLASS, USER_ATTRIBUTE, ""));
+  }
+
+  @Test
+  void configuredAttributeNamesAreEscapedAsWell() {
+    assertEquals("(&(objectclass=per\\29son)(u\\28id=*alice*))",
+        
ShiroAuthenticationService.buildUserSearchFilterWithObjectClass("per)son", 
"u(id",
+            "alice"));
+  }
+
+  @Test
+  void backslashAndNulInSearchTextAreEscaped() {
+    assertEquals("(uid=*alice\\5c*)",
+        ShiroAuthenticationService.buildUserSearchFilter(USER_ATTRIBUTE, 
"alice\\"));
+    assertEquals("(uid=*alice\\00*)",
+        ShiroAuthenticationService.buildUserSearchFilter(USER_ATTRIBUTE, 
"alice\0"));
+    assertEquals("(&(objectclass=person)(uid=*alice\\5c\\00*))",
+        ShiroAuthenticationService.buildUserSearchFilterWithObjectClass(
+            USER_OBJECT_CLASS, USER_ATTRIBUTE, "alice\\\0"));
+  }
+
+  private static void assertMetacharacterCounts(String rendered, String 
payload,
+      int expectedOpenParens, int expectedCloseParens, int expectedAsterisks) {
+    assertEquals(expectedOpenParens, count(rendered, '('),
+        "extra unescaped '(' from payload: " + rendered);
+    assertEquals(expectedCloseParens, count(rendered, ')'),
+        "extra unescaped ')' from payload: " + rendered);
+    assertEquals(expectedAsterisks, count(rendered, '*'),
+        "extra unescaped '*' from payload: " + rendered);
+
+    if (payload.indexOf('(') >= 0) {
+      assertTrue(rendered.contains("\\28"), "missing \\28 in: " + rendered);
+    }
+    if (payload.indexOf(')') >= 0) {
+      assertTrue(rendered.contains("\\29"), "missing \\29 in: " + rendered);
+    }
+    if (payload.indexOf('*') >= 0) {
+      assertTrue(rendered.contains("\\2a"), "missing \\2a in: " + rendered);
+    }
+    if (payload.indexOf('\\') >= 0) {
+      assertTrue(rendered.contains("\\5c"), "missing \\5c in: " + rendered);
+    }
+    if (payload.indexOf('\0') >= 0) {
+      assertTrue(rendered.contains("\\00"), "missing \\00 in: " + rendered);
+    }
+  }
+
+  /**
+   * Counts occurrences of {@code ch} in the rendered filter. {@code 
LdapFilterEncoder} replaces
+   * every metacharacter with a hex escape such as {@code \2a}, so a 
metacharacter that is still
+   * present as itself is by definition an unescaped one.
+   */
+  private static int count(String s, char ch) {
+    int count = 0;
+    for (int i = 0; i < s.length(); i++) {
+      if (s.charAt(i) == ch) {
+        count++;
+      }
+    }
+    return count;
+  }
+}

Reply via email to