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 def99c3c12 [MINOR] Apply proper RFC 4515 / RFC 4514 escaping in LDAP 
realms (#5249)
def99c3c12 is described below

commit def99c3c12ac03b183fea2c903575f5ae6c693d5
Author: Jongyoul Lee <[email protected]>
AuthorDate: Thu May 14 15:04:10 2026 +0900

    [MINOR] Apply proper RFC 4515 / RFC 4514 escaping in LDAP realms (#5249)
---
 zeppelin-server/pom.xml                            |   5 +
 .../zeppelin/realm/ActiveDirectoryGroupRealm.java  |   8 +-
 .../apache/zeppelin/realm/LdapFilterEncoder.java   | Bin 0 -> 2576 bytes
 .../java/org/apache/zeppelin/realm/LdapRealm.java  |  59 ++++-
 ...tiveDirectoryGroupRealmFilterInjectionTest.java | 168 ++++++++++++++
 .../zeppelin/realm/LdapFilterEncoderFuzzTest.java  | 258 +++++++++++++++++++++
 .../zeppelin/realm/LdapFilterEncoderTest.java      | 122 ++++++++++
 .../zeppelin/realm/LdapRealmDnInjectionTest.java   | 232 ++++++++++++++++++
 .../realm/LdapRealmFilterInjectionTest.java        | 127 ++++++++++
 .../org/apache/zeppelin/realm/LdapRealmTest.java   |  11 +-
 10 files changed, 973 insertions(+), 17 deletions(-)

diff --git a/zeppelin-server/pom.xml b/zeppelin-server/pom.xml
index 4d7ee67c66..6948370ed9 100644
--- a/zeppelin-server/pom.xml
+++ b/zeppelin-server/pom.xml
@@ -49,6 +49,11 @@
   </properties>
 
   <dependencies>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-params</artifactId>
+      <scope>test</scope>
+    </dependency>
 
     <dependency>
       <groupId>${project.groupId}</groupId>
diff --git 
a/zeppelin-server/src/main/java/org/apache/zeppelin/realm/ActiveDirectoryGroupRealm.java
 
b/zeppelin-server/src/main/java/org/apache/zeppelin/realm/ActiveDirectoryGroupRealm.java
index c41da54964..296b687797 100644
--- 
a/zeppelin-server/src/main/java/org/apache/zeppelin/realm/ActiveDirectoryGroupRealm.java
+++ 
b/zeppelin-server/src/main/java/org/apache/zeppelin/realm/ActiveDirectoryGroupRealm.java
@@ -255,7 +255,9 @@ public class ActiveDirectoryGroupRealm extends 
AbstractLdapRealm {
     searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);
     searchCtls.setCountLimit(numUsersToFetch);
 
-    String searchFilter = String.format("(&(objectClass=*)(%s=*%s*))", 
this.getUserSearchAttributeName(), containString);
+    String searchFilter = String.format("(&(objectClass=*)(%s=*%s*))",
+        LdapFilterEncoder.escapeFilterValue(this.getUserSearchAttributeName()),
+        LdapFilterEncoder.escapeFilterValue(containString));
 
     Object[] searchArguments = new Object[]{containString};
 
@@ -301,7 +303,9 @@ public class ActiveDirectoryGroupRealm extends 
AbstractLdapRealm {
       userPrincipalName = userPrincipalName.split("@")[0];
     }
 
-    String searchFilter = String.format("(&(objectClass=*)(%s=%s))", 
this.getUserSearchAttributeName(), userPrincipalName);
+    String searchFilter = String.format("(&(objectClass=*)(%s=%s))",
+        LdapFilterEncoder.escapeFilterValue(this.getUserSearchAttributeName()),
+        LdapFilterEncoder.escapeFilterValue(userPrincipalName));
     Object[] searchArguments = new Object[]{userPrincipalName};
 
     NamingEnumeration<SearchResult> answer = ldapContext.search(searchBase, 
searchFilter, searchArguments,
diff --git 
a/zeppelin-server/src/main/java/org/apache/zeppelin/realm/LdapFilterEncoder.java
 
b/zeppelin-server/src/main/java/org/apache/zeppelin/realm/LdapFilterEncoder.java
new file mode 100644
index 0000000000..e987965436
Binary files /dev/null and 
b/zeppelin-server/src/main/java/org/apache/zeppelin/realm/LdapFilterEncoder.java
 differ
diff --git 
a/zeppelin-server/src/main/java/org/apache/zeppelin/realm/LdapRealm.java 
b/zeppelin-server/src/main/java/org/apache/zeppelin/realm/LdapRealm.java
index e1d6d694c7..be8a0f0c68 100644
--- a/zeppelin-server/src/main/java/org/apache/zeppelin/realm/LdapRealm.java
+++ b/zeppelin-server/src/main/java/org/apache/zeppelin/realm/LdapRealm.java
@@ -363,7 +363,10 @@ public class LdapRealm extends DefaultLdapRealm {
           searchResultEnum = ldapCtx.search(
               getGroupSearchBase(),
               String.format(
-                  MATCHING_RULE_IN_CHAIN_FORMAT, groupObjectClass, 
memberAttribute, userDn),
+                  MATCHING_RULE_IN_CHAIN_FORMAT,
+                  LdapFilterEncoder.escapeFilterValue(groupObjectClass),
+                  LdapFilterEncoder.escapeFilterValue(memberAttribute),
+                  LdapFilterEncoder.escapeFilterValue(userDn)),
               searchControls);
           while (searchResultEnum != null && searchResultEnum.hasMore()) {
             // searchResults contains all the groups in search scope
@@ -382,11 +385,12 @@ public class LdapRealm extends DefaultLdapRealm {
           }
         } else {
           // Default group search filter
-          String searchFilter = String.format("(objectclass=%1$s)", 
groupObjectClass);
+          String searchFilter = String.format("(objectclass=%1$s)",
+              LdapFilterEncoder.escapeFilterValue(groupObjectClass));
 
           // If group search filter is defined in Shiro config, then use it
           if (groupSearchFilter != null) {
-            searchFilter = expandTemplate(groupSearchFilter, userName);
+            searchFilter = expandFilterTemplate(groupSearchFilter, userName);
             //searchFilter = String.format("%1$s", groupSearchFilter);
           }
           LOGGER.debug("Group SearchBase|SearchFilter|GroupSearchScope: " + 
"{}|{}|{}",
@@ -773,7 +777,7 @@ public class LdapRealm extends DefaultLdapRealm {
   }
 
   public void setUserSearchFilter(final String filter) {
-    this.userSearchFilter = (filter == null ? null : 
escapeAttributeValue(filter.trim()));
+    this.userSearchFilter = (filter == null ? null : filter.trim());
   }
 
   public String getGroupSearchFilter() {
@@ -781,7 +785,7 @@ public class LdapRealm extends DefaultLdapRealm {
   }
 
   public void setGroupSearchFilter(final String filter) {
-    this.groupSearchFilter = (filter == null ? null : 
escapeAttributeValue(filter.trim()));
+    this.groupSearchFilter = (filter == null ? null : filter.trim());
   }
 
   public boolean getUserLowerCase() {
@@ -886,24 +890,32 @@ public class LdapRealm extends DefaultLdapRealm {
     // If not searching use the userDnTemplate and return.
     if ((userSearchBase == null || userSearchBase.isEmpty()) || 
(userSearchAttributeName == null
         && userSearchFilter == null && 
!"object".equalsIgnoreCase(userSearchScope))) {
-      userDn = expandTemplate(userDnTemplate, matchedPrincipal);
+      // Bare-placeholder template: principal is already a full DN; escaping
+      // its commas would collapse the RDNs and break binds.
+      if (MEMBER_SUBSTITUTION_TOKEN.equals(userDnTemplate)) {
+        userDn = matchedPrincipal;
+      } else {
+        userDn = expandDnTemplate(userDnTemplate, matchedPrincipal);
+      }
       LOGGER.debug("LDAP UserDN and Principal: {},{}", userDn, principal);
       return userDn;
     }
 
     // Create the searchBase and searchFilter from config.
-    String searchBase = expandTemplate(getUserSearchBase(), matchedPrincipal);
+    String searchBase = expandDnTemplate(getUserSearchBase(), 
matchedPrincipal);
     String searchFilter;
     if (userSearchFilter == null) {
       if (userSearchAttributeName == null) {
-        searchFilter = String.format("(objectclass=%1$s)", 
getUserObjectClass());
+        searchFilter = String.format("(objectclass=%1$s)",
+            LdapFilterEncoder.escapeFilterValue(getUserObjectClass()));
       } else {
-        searchFilter = String.format("(&(objectclass=%1$s)(%2$s=%3$s))", 
getUserObjectClass(),
-            userSearchAttributeName, 
expandTemplate(getUserSearchAttributeTemplate(),
-                matchedPrincipal));
+        searchFilter = String.format("(&(objectclass=%1$s)(%2$s=%3$s))",
+            LdapFilterEncoder.escapeFilterValue(getUserObjectClass()),
+            LdapFilterEncoder.escapeFilterValue(userSearchAttributeName),
+            expandFilterTemplate(getUserSearchAttributeTemplate(), 
matchedPrincipal));
       }
     } else {
-      searchFilter = expandTemplate(userSearchFilter, matchedPrincipal);
+      searchFilter = expandFilterTemplate(userSearchFilter, matchedPrincipal);
     }
     SearchControls searchControls = getUserSearchControls();
 
@@ -1026,4 +1038,27 @@ public class LdapRealm extends DefaultLdapRealm {
   protected static final String expandTemplate(final String template, final 
String input) {
     return template.replace(MEMBER_SUBSTITUTION_TOKEN, input);
   }
+
+  /**
+   * Expands a template that will be embedded in an LDAP search filter.
+   * The {@code input} value is RFC 4515 escaped before substitution so that
+   * user-controlled metacharacters cannot inject filter clauses.
+   */
+  protected static final String expandFilterTemplate(final String template, 
final String input) {
+    String escaped = LdapFilterEncoder.escapeFilterValue(input);
+    return template.replace(MEMBER_SUBSTITUTION_TOKEN, escaped == null ? "" : 
escaped);
+  }
+
+  /**
+   * Expands a template that produces a Distinguished Name or DN component
+   * (e.g. {@code userDnTemplate}, {@code userSearchBase}). The {@code input}
+   * value is RFC 4514 escaped via the existing {@link #escapeAttributeValue}
+   * before substitution so that user-controlled DN metacharacters such as
+   * {@code ,}, {@code +} or leading {@code #} cannot break out of their RDN
+   * or inject additional RDNs.
+   */
+  protected final String expandDnTemplate(final String template, final String 
input) {
+    String escaped = escapeAttributeValue(input);
+    return template.replace(MEMBER_SUBSTITUTION_TOKEN, escaped == null ? "" : 
escaped);
+  }
 }
diff --git 
a/zeppelin-server/src/test/java/org/apache/zeppelin/realm/ActiveDirectoryGroupRealmFilterInjectionTest.java
 
b/zeppelin-server/src/test/java/org/apache/zeppelin/realm/ActiveDirectoryGroupRealmFilterInjectionTest.java
new file mode 100644
index 0000000000..9c739a3cfb
--- /dev/null
+++ 
b/zeppelin-server/src/test/java/org/apache/zeppelin/realm/ActiveDirectoryGroupRealmFilterInjectionTest.java
@@ -0,0 +1,168 @@
+/*
+ * 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.realm;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import javax.naming.NamingEnumeration;
+import javax.naming.directory.SearchControls;
+import javax.naming.directory.SearchResult;
+import javax.naming.ldap.LdapContext;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+
+/**
+ * Verifies that {@link ActiveDirectoryGroupRealm}'s LDAP search calls receive 
a
+ * filter string that has every user-controlled metacharacter RFC 4515 escaped.
+ * Captures the actual filter argument passed to the mocked
+ * {@link LdapContext#search} and asserts the absence of unescaped
+ * {@code (}, {@code )}, {@code *} originating from the user payload.
+ */
+class ActiveDirectoryGroupRealmFilterInjectionTest {
+
+  @ParameterizedTest
+  @ValueSource(strings = {
+      ")(objectClass=*",
+      "admin)(|(uid=*",
+      "*",
+      "admin)(cn=a*",
+      ")(mail=*@corp.com"
+  })
+  void searchForUserName_escapes_user_payload(String payload) throws Exception 
{
+    LdapContext ctx = mock(LdapContext.class);
+    NamingEnumeration<SearchResult> empty = mock(NamingEnumeration.class);
+    when(empty.hasMoreElements()).thenReturn(false);
+    when(ctx.search(anyString(), anyString(), any(), 
any(SearchControls.class)))
+        .thenReturn(empty);
+
+    ActiveDirectoryGroupRealm realm = new ActiveDirectoryGroupRealm();
+    realm.setSearchBase("dc=example,dc=com");
+
+    realm.searchForUserName(payload, ctx, 100);
+
+    ArgumentCaptor<String> filterCap = ArgumentCaptor.forClass(String.class);
+    verify(ctx).search(eq("dc=example,dc=com"),
+        filterCap.capture(), any(), any(SearchControls.class));
+    assertNoUnescapedMetacharsFromPayload(filterCap.getValue(), payload);
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {
+      ")(objectClass=*",
+      "admin)(|(uid=*",
+      "*",
+      "admin)(cn=a*",
+      ")(mail=*@corp.com"
+  })
+  void getRoleNamesForUser_escapes_user_payload(String payload) throws 
Exception {
+    // Exercise the second String.format filter site directly. The method is
+    // private, so use reflection to invoke it.
+    LdapContext ctx = mock(LdapContext.class);
+    NamingEnumeration<SearchResult> empty = mock(NamingEnumeration.class);
+    when(empty.hasMoreElements()).thenReturn(false);
+    when(ctx.search(anyString(), anyString(), any(), 
any(SearchControls.class)))
+        .thenReturn(empty);
+
+    ActiveDirectoryGroupRealm realm = new ActiveDirectoryGroupRealm();
+    realm.setSearchBase("dc=example,dc=com");
+
+    java.lang.reflect.Method method =
+        ActiveDirectoryGroupRealm.class.getDeclaredMethod(
+            "getRoleNamesForUser", String.class, LdapContext.class);
+    method.setAccessible(true);
+    method.invoke(realm, payload, ctx);
+
+    ArgumentCaptor<String> filterCap = ArgumentCaptor.forClass(String.class);
+    verify(ctx).search(anyString(), filterCap.capture(), any(),
+        any(SearchControls.class));
+    assertNoUnescapedMetacharsFromPayload(filterCap.getValue(), payload);
+  }
+
+  @Test
+  void normal_username_passes_through_unchanged() throws Exception {
+    LdapContext ctx = mock(LdapContext.class);
+    NamingEnumeration<SearchResult> empty = mock(NamingEnumeration.class);
+    when(empty.hasMoreElements()).thenReturn(false);
+    when(ctx.search(anyString(), anyString(), any(), 
any(SearchControls.class)))
+        .thenReturn(empty);
+
+    ActiveDirectoryGroupRealm realm = new ActiveDirectoryGroupRealm();
+    realm.setSearchBase("dc=example,dc=com");
+    realm.searchForUserName("alice", ctx, 100);
+
+    ArgumentCaptor<String> filterCap = ArgumentCaptor.forClass(String.class);
+    verify(ctx).search(anyString(), filterCap.capture(), any(),
+        any(SearchControls.class));
+    assertEquals("(&(objectClass=*)(sAMAccountName=*alice*))", 
filterCap.getValue());
+  }
+
+  /**
+   * For each metacharacter in {@code payload}, assert the rendered filter does
+   * NOT contain that character unescaped. We do that by removing the literal
+   * filter template (the parts that come from the static format string) before
+   * checking for raw metacharacters.
+   *
+   * <p>Both vulnerable callsites use templates of the form
+   * {@code "(&(objectClass=*)(<attr>=...<v>...))"} which contribute exactly
+   * 3 '(' and 3 ')'. The asterisk count varies between the two templates
+   * (3 for searchForUserName, 1 for getRoleNamesForUser), so we don't assert
+   * it absolutely; we only assert that escape sequences are present whenever
+   * the payload contained the corresponding metacharacter.
+   */
+  private static void assertNoUnescapedMetacharsFromPayload(String filter, 
String payload) {
+    if (payload.indexOf('(') >= 0) {
+      assertTrue(filter.contains("\\28"),
+          "expected \\28 escape sequence for '(' in payload " + payload + ": " 
+ filter);
+    }
+    if (payload.indexOf(')') >= 0) {
+      assertTrue(filter.contains("\\29"),
+          "expected \\29 escape sequence for ')' in payload " + payload + ": " 
+ filter);
+    }
+    if (payload.indexOf('*') >= 0) {
+      assertTrue(filter.contains("\\2a"),
+          "expected \\2a escape sequence for '*' in payload " + payload + ": " 
+ filter);
+    }
+    // After stripping every "\xNN" hex escape, the remaining "skeleton" must
+    // match exactly the static template — no extra '(' / ')' / '*' that came
+    // from the payload.
+    String stripped = filter.replaceAll("\\\\[0-9a-fA-F]{2}", "");
+    assertEquals(3, count(stripped, '('),
+        "unexpected '(' count after stripping escapes: " + stripped + " 
(payload=" + payload + ")");
+    assertEquals(3, count(stripped, ')'),
+        "unexpected ')' count after stripping escapes: " + stripped + " 
(payload=" + payload + ")");
+  }
+
+  private static int count(String s, char ch) {
+    int n = 0;
+    for (int i = 0; i < s.length(); i++) {
+      if (s.charAt(i) == ch) {
+        n++;
+      }
+    }
+    return n;
+  }
+}
diff --git 
a/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapFilterEncoderFuzzTest.java
 
b/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapFilterEncoderFuzzTest.java
new file mode 100644
index 0000000000..7ec115260f
--- /dev/null
+++ 
b/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapFilterEncoderFuzzTest.java
@@ -0,0 +1,258 @@
+/*
+ * 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.realm;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import java.util.Random;
+import org.junit.jupiter.api.RepeatedTest;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Fuzz tests for {@link LdapFilterEncoder#escapeFilterValue}.
+ *
+ * <p>Uses a deterministic seed ({@code 0xDEADBEEFL}) for reproducibility.
+ * Each repeated test iteration generates a random payload and verifies:
+ * <ul>
+ *   <li>No raw {@code (}, {@code )}, {@code *} in the output (unless preceded 
by {@code \})</li>
+ *   <li>No NUL byte in the output</li>
+ *   <li>Every escape sequence uses the exact RFC 4515 hex encoding</li>
+ *   <li>Decoding the escaped output produces the original input</li>
+ * </ul>
+ */
+class LdapFilterEncoderFuzzTest {
+
+  // Deterministic seed — guarantees reproducibility across runs.
+  private static final Random RANDOM = new Random(0xDEADBEEFL);
+
+  // RFC 4515 metacharacters that must be escaped.
+  private static final char[] METACHAR = {'\\', '(', ')', '*', '\0'};
+
+  // Printable ASCII range (0x20–0x7E).
+  private static final int ASCII_PRINTABLE_START = 0x20;
+  private static final int ASCII_PRINTABLE_END = 0x7E;
+
+  // Korean Hangul syllables: U+AC00 – U+D7A3
+  private static final int HANGUL_START = 0xAC00;
+  private static final int HANGUL_END = 0xD7A3;
+
+  // Hiragana: U+3041 – U+3096
+  private static final int HIRAGANA_START = 0x3041;
+  private static final int HIRAGANA_END = 0x3096;
+
+  /**
+   * Generates a random payload of length [0, 200] with a weighted mix of
+   * ASCII printable characters, RFC 4515 metacharacters, Hangul syllables,
+   * and Hiragana characters.
+   */
+  private static String randomPayload() {
+    int length = RANDOM.nextInt(201); // 0 to 200 inclusive
+    StringBuilder sb = new StringBuilder(length);
+    for (int i = 0; i < length; i++) {
+      sb.appendCodePoint(randomCodePoint());
+    }
+    return sb.toString();
+  }
+
+  /**
+   * Returns a random code point from one of four buckets (weighted):
+   * <ul>
+   *   <li>30% — RFC 4515 metacharacter</li>
+   *   <li>50% — ASCII printable</li>
+   *   <li>10% — Hangul syllable</li>
+   *   <li>10% — Hiragana</li>
+   * </ul>
+   */
+  private static int randomCodePoint() {
+    int bucket = RANDOM.nextInt(100);
+    if (bucket < 30) {
+      // Metachar
+      return METACHAR[RANDOM.nextInt(METACHAR.length)];
+    } else if (bucket < 80) {
+      // ASCII printable
+      return ASCII_PRINTABLE_START + RANDOM.nextInt(ASCII_PRINTABLE_END - 
ASCII_PRINTABLE_START + 1);
+    } else if (bucket < 90) {
+      // Hangul
+      return HANGUL_START + RANDOM.nextInt(HANGUL_END - HANGUL_START + 1);
+    } else {
+      // Hiragana
+      return HIRAGANA_START + RANDOM.nextInt(HIRAGANA_END - HIRAGANA_START + 
1);
+    }
+  }
+
+  /**
+   * Decodes an RFC 4515-escaped string back to the original value.
+   * This is the inverse of {@code LdapFilterEncoder.escapeFilterValue}.
+   */
+  private static String decode(String escaped) {
+    StringBuilder sb = new StringBuilder();
+    int i = 0;
+    while (i < escaped.length()) {
+      char c = escaped.charAt(i);
+      if (c == '\\' && i + 2 < escaped.length()) {
+        String hex = escaped.substring(i + 1, i + 3);
+        int codePoint = Integer.parseInt(hex, 16);
+        sb.append((char) codePoint);
+        i += 3;
+      } else {
+        sb.appendCodePoint(c);
+        i++;
+      }
+    }
+    return sb.toString();
+  }
+
+  /**
+   * Verifies that the escaped output contains no raw metacharacters.
+   *
+   * <p>A raw metacharacter is one that appears in the output without being
+   * preceded by a {@code \} escape prefix. We check this by scanning the
+   * output character by character: whenever we see {@code \} we skip the
+   * next two characters (the hex pair), otherwise the character must not
+   * be a metacharacter.
+   */
+  private static void assertNoRawMetachars(String escaped) {
+    int i = 0;
+    while (i < escaped.length()) {
+      char c = escaped.charAt(i);
+      if (c == '\\') {
+        // This backslash must be part of an escape sequence — skip 3 chars 
total.
+        i += 3;
+      } else {
+        // Must not be a raw metacharacter.
+        assertFalse(c == '(' || c == ')' || c == '*' || c == '\0',
+            () -> "Raw metacharacter '" + (c == '\0' ? "NUL" : c) + "' found 
in escaped output: " + escaped);
+        i++;
+      }
+    }
+  }
+
+  /**
+   * Verifies that every escape sequence in the output uses the exact
+   * RFC 4515 hex encoding ({@code \5c}, {@code \28}, {@code \29},
+   * {@code \2a}, {@code \00}).
+   */
+  private static void assertValidEscapeSequences(String escaped) {
+    int i = 0;
+    while (i < escaped.length()) {
+      char c = escaped.charAt(i);
+      if (c == '\\') {
+        // There must be exactly 2 hex digits following.
+        assertFalse(i + 2 >= escaped.length(),
+            () -> "Escape sequence at end of string is truncated: " + escaped);
+        String hex = escaped.substring(i + 1, i + 3).toLowerCase();
+        // Only these 5 sequences are valid RFC 4515 escape outputs.
+        boolean validHex = hex.equals("5c") || hex.equals("28")
+            || hex.equals("29") || hex.equals("2a") || hex.equals("00");
+        String finalHex = hex;
+        assertFalse(!validHex,
+            () -> "Unexpected escape sequence \\" + finalHex + " in: " + 
escaped);
+        i += 3;
+      } else {
+        i++;
+      }
+    }
+  }
+
+  // -------------------------------------------------------------------------
+  // Fuzz test: 1000 random payloads
+  // -------------------------------------------------------------------------
+
+  @RepeatedTest(1000)
+  void fuzzRandomPayload() {
+    String payload = randomPayload();
+    String escaped = LdapFilterEncoder.escapeFilterValue(payload);
+
+    // 1. No NUL byte in output.
+    assertFalse(escaped.contains("\0"),
+        () -> "NUL byte found in escaped output for input: " + payload);
+
+    // 2. No raw ( ) * in output.
+    assertNoRawMetachars(escaped);
+
+    // 3. Every escape sequence uses the correct RFC 4515 hex encoding.
+    assertValidEscapeSequences(escaped);
+
+    // 4. Round-trip: decode(escape(input)) == input.
+    assertEquals(payload, decode(escaped),
+        () -> "Round-trip failed for input: " + payload);
+  }
+
+  // -------------------------------------------------------------------------
+  // Edge-case tests
+  // -------------------------------------------------------------------------
+
+  @Test
+  void edgeCaseEmptyString() {
+    assertEquals("", LdapFilterEncoder.escapeFilterValue(""));
+  }
+
+  @Test
+  void edgeCaseVeryLongAllMetachars() {
+    // 10 000 characters, all metacharacters (cycling through the 5).
+    StringBuilder input = new StringBuilder(10_000);
+    for (int i = 0; i < 10_000; i++) {
+      input.append(METACHAR[i % METACHAR.length]);
+    }
+    String payload = input.toString();
+    String escaped = LdapFilterEncoder.escapeFilterValue(payload);
+
+    assertFalse(escaped.contains("\0"), "NUL byte must not appear in output");
+    assertNoRawMetachars(escaped);
+    assertValidEscapeSequences(escaped);
+    assertEquals(payload, decode(escaped), "Round-trip must hold for 10 
000-char metachar-only input");
+  }
+
+  @Test
+  void edgeCaseHangulOnly() {
+    // 200 Hangul syllables — no metacharacters, output must equal input.
+    StringBuilder input = new StringBuilder(200);
+    Random r = new Random(0xCAFEBABEL);
+    for (int i = 0; i < 200; i++) {
+      input.appendCodePoint(HANGUL_START + r.nextInt(HANGUL_END - HANGUL_START 
+ 1));
+    }
+    String payload = input.toString();
+    String escaped = LdapFilterEncoder.escapeFilterValue(payload);
+
+    assertEquals(payload, escaped,
+        "Hangul-only input should pass through unchanged");
+  }
+
+  @Test
+  void edgeCaseHangulMixedWithMetachars() {
+    // Hangul syllables interleaved with all 5 metacharacters.
+    String payload = "가(각)갂*갃\\간\0갅";
+    String escaped = LdapFilterEncoder.escapeFilterValue(payload);
+
+    assertFalse(escaped.contains("\0"), "NUL must be escaped");
+    assertNoRawMetachars(escaped);
+    assertValidEscapeSequences(escaped);
+    assertEquals(payload, decode(escaped), "Round-trip must hold for 
Hangul+metachar mix");
+  }
+
+  @Test
+  void edgeCaseUnicodeFullwidthMetacharsAreNotEscaped() {
+    // Unicode fullwidth equivalents (U+FF08 '(', U+FF09 ')', U+FF0A '*')
+    // are NOT RFC 4515 metacharacters and must pass through unchanged.
+    // This is intentional and documents the current scope of the fix.
+    String fullwidth = "()*";
+    String escaped = LdapFilterEncoder.escapeFilterValue(fullwidth);
+    assertEquals(fullwidth, escaped,
+        "Unicode fullwidth metachar equivalents should NOT be escaped (out of 
RFC 4515 scope)");
+  }
+}
diff --git 
a/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapFilterEncoderTest.java
 
b/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapFilterEncoderTest.java
new file mode 100644
index 0000000000..9d5cbc7047
--- /dev/null
+++ 
b/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapFilterEncoderTest.java
@@ -0,0 +1,122 @@
+/*
+ * 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.realm;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.junit.jupiter.api.Test;
+
+class LdapFilterEncoderTest {
+
+  @Test
+  void nullInputReturnsNull() {
+    assertNull(LdapFilterEncoder.escapeFilterValue(null));
+  }
+
+  @Test
+  void emptyInputReturnsEmpty() {
+    assertEquals("", LdapFilterEncoder.escapeFilterValue(""));
+  }
+
+  @Test
+  void plainTextIsUnchanged() {
+    assertEquals("alice", LdapFilterEncoder.escapeFilterValue("alice"));
+    assertEquals("[email protected]", 
LdapFilterEncoder.escapeFilterValue("[email protected]"));
+    assertEquals("Alice Smith", LdapFilterEncoder.escapeFilterValue("Alice 
Smith"));
+  }
+
+  @Test
+  void parenthesesAreEscaped() {
+    assertEquals("\\28alice\\29", 
LdapFilterEncoder.escapeFilterValue("(alice)"));
+  }
+
+  @Test
+  void asteriskIsEscaped() {
+    assertEquals("\\2a", LdapFilterEncoder.escapeFilterValue("*"));
+    assertEquals("foo\\2abar", LdapFilterEncoder.escapeFilterValue("foo*bar"));
+  }
+
+  @Test
+  void backslashIsEscaped() {
+    assertEquals("\\5c", LdapFilterEncoder.escapeFilterValue("\\"));
+  }
+
+  @Test
+  void nullByteIsEscaped() {
+    assertEquals("\\00", LdapFilterEncoder.escapeFilterValue("\0"));
+  }
+
+  // The following payloads were demonstrated by the security report and would
+  // bypass an RFC 4514 (DN) escape function. Each must be neutralized.
+
+  @Test
+  void filterClosingPayloadIsNeutralized() {
+    // ")(uid=*" — closes the current filter and injects a wildcard match
+    String escaped = LdapFilterEncoder.escapeFilterValue(")(uid=*");
+    assertEquals("\\29\\28uid=\\2a", escaped);
+  }
+
+  @Test
+  void orInjectionPayloadIsNeutralized() {
+    // "admin)(|(uid=*" — OR-injection bypassing password check
+    String escaped = LdapFilterEncoder.escapeFilterValue("admin)(|(uid=*");
+    assertEquals("admin\\29\\28|\\28uid=\\2a", escaped);
+  }
+
+  @Test
+  void wildcardOnlyPayloadIsNeutralized() {
+    // username "*" alone would match all entries without escape
+    assertEquals("\\2a", LdapFilterEncoder.escapeFilterValue("*"));
+  }
+
+  @Test
+  void blindLdapPrefixPayloadIsNeutralized() {
+    // "admin)(cn=a*" — used for character-by-character attribute enumeration
+    String escaped = LdapFilterEncoder.escapeFilterValue("admin)(cn=a*");
+    assertEquals("admin\\29\\28cn=a\\2a", escaped);
+  }
+
+  @Test
+  void emailEnumerationPayloadIsNeutralized() {
+    // ")(mail=*@corp.com" — used to enumerate users in a target email domain
+    String escaped = LdapFilterEncoder.escapeFilterValue(")(mail=*@corp.com");
+    assertEquals("\\29\\28mail=\\[email protected]", escaped);
+  }
+
+  @Test
+  void mixedSpecialCharactersAreAllEscaped() {
+    // every metacharacter at once
+    String input = "(*)\\\0";
+    assertEquals("\\28\\2a\\29\\5c\\00", 
LdapFilterEncoder.escapeFilterValue(input));
+  }
+
+  @Test
+  void rfc4514OnlyMetacharactersAreLeftAlone() {
+    // characters that RFC 4514 (DN) escapes but RFC 4515 (filter) does NOT 
must
+    // pass through untouched. This regression-tests the exact gap between the
+    // two RFCs that produced the original CVE-2024-31867 incomplete fix.
+    assertEquals(",", LdapFilterEncoder.escapeFilterValue(","));
+    assertEquals("+", LdapFilterEncoder.escapeFilterValue("+"));
+    assertEquals(";", LdapFilterEncoder.escapeFilterValue(";"));
+    assertEquals("\"", LdapFilterEncoder.escapeFilterValue("\""));
+    assertEquals("<", LdapFilterEncoder.escapeFilterValue("<"));
+    assertEquals(">", LdapFilterEncoder.escapeFilterValue(">"));
+    assertEquals("=", LdapFilterEncoder.escapeFilterValue("="));
+    assertEquals("#", LdapFilterEncoder.escapeFilterValue("#"));
+  }
+}
diff --git 
a/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapRealmDnInjectionTest.java
 
b/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapRealmDnInjectionTest.java
new file mode 100644
index 0000000000..20abecdd03
--- /dev/null
+++ 
b/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapRealmDnInjectionTest.java
@@ -0,0 +1,232 @@
+/*
+ * 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.realm;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * Regression tests verifying that user-controlled values flowing through
+ * {@link LdapRealm#expandDnTemplate(String, String)} cannot inject RFC 4514 DN
+ * metacharacters such as {@code ,}, {@code +}, or leading {@code #}.
+ *
+ * <p>The most dangerous metacharacters in a DN context are {@code ,} (RDN
+ * separator) and {@code +} (multi-valued RDN separator). Injecting these 
allows
+ * an attacker to append additional RDN components to the constructed DN.
+ */
+class LdapRealmDnInjectionTest {
+
+  // Typical userDnTemplate used in Zeppelin shiro.ini:
+  //   ldapRealm.userDnTemplate = uid={0},ou=users,dc=example,dc=com
+  private static final String DN_TEMPLATE = 
"uid={0},ou=users,dc=example,dc=com";
+
+  /**
+   * Minimal concrete subclass that exposes the two protected methods under 
test.
+   * No LDAP connection is established.
+   */
+  private static class TestableLdapRealm extends LdapRealm {
+    String callExpandDnTemplate(String template, String input) {
+      return expandDnTemplate(template, input);
+    }
+
+    String callEscapeAttributeValue(String input) {
+      return escapeAttributeValue(input);
+    }
+  }
+
+  private TestableLdapRealm realm;
+
+  @BeforeEach
+  void setUp() {
+    realm = new TestableLdapRealm();
+  }
+
+  // -----------------------------------------------------------------------
+  // PoC injection payloads: the rendered DN must not contain unescaped
+  // RFC 4514 metacharacters (comma, plus) that originated from user input.
+  // DN_TEMPLATE has exactly 3 bare commas and 0 bare plus signs.
+  // -----------------------------------------------------------------------
+
+  @ParameterizedTest
+  @ValueSource(strings = {
+      ",uid=admin,dc=foo",       // classic extra-RDN injection via comma
+      ",cn=admin",               // inject another RDN via comma
+      "\\,",                     // attempt to pass a literal backslash-comma
+      "+admin=true",             // RFC 4514 multi-valued RDN separator
+      "#secret",                 // leading '#' (BER encoding marker in RFC 
4514)
+      "alice,dc=evil,dc=com",    // full suffix injection
+  })
+  void dn_injection_payloads_are_neutralized(String payload) {
+    String rendered = realm.callExpandDnTemplate(DN_TEMPLATE, payload);
+
+    // The rendered DN must not contain more bare commas than the template 
itself.
+    // DN_TEMPLATE has exactly 3 commas: after uid={0}, ou=users, dc=example.
+    // Any extra bare comma means an RDN was injected from the payload.
+    int bareCommas = countUnescaped(rendered, ',');
+    assertEquals(3, bareCommas,
+        "unexpected bare ',' (possible RDN injection) in rendered DN: " + 
rendered);
+
+    // The rendered DN must not contain a bare '+' from the payload.
+    // DN_TEMPLATE has 0 '+' signs.
+    int barePlus = countUnescaped(rendered, '+');
+    assertEquals(0, barePlus,
+        "unexpected bare '+' (possible multi-valued RDN injection) in: " + 
rendered);
+
+    // If the payload contained a comma it must appear escaped as \2C in the 
output.
+    if (payload.indexOf(',') >= 0) {
+      assertTrue(rendered.contains("\\2C"),
+          "comma from payload not escaped as \\2C in: " + rendered);
+    }
+
+    // If the payload contained '+' it must appear escaped as \2B.
+    if (payload.indexOf('+') >= 0) {
+      assertTrue(rendered.contains("\\2B"),
+          "'+' from payload not escaped as \\2B in: " + rendered);
+    }
+  }
+
+  // -----------------------------------------------------------------------
+  // Regression: well-formed usernames must pass through without distortion.
+  // -----------------------------------------------------------------------
+
+  @Test
+  void normal_ascii_username_passes_through_unchanged() {
+    String rendered = realm.callExpandDnTemplate(DN_TEMPLATE, "alice");
+    assertEquals("uid=alice,ou=users,dc=example,dc=com", rendered);
+  }
+
+  @Test
+  void username_with_spaces_in_middle_passes_through_unchanged() {
+    // Internal spaces are not special in RFC 4514 attribute values.
+    String rendered = realm.callExpandDnTemplate(DN_TEMPLATE, "john doe");
+    assertEquals("uid=john doe,ou=users,dc=example,dc=com", rendered);
+  }
+
+  @Test
+  void username_with_leading_space_is_escaped() {
+    // RFC 4514: leading space must be escaped.
+    String rendered = realm.callExpandDnTemplate(DN_TEMPLATE, " alice");
+    assertTrue(rendered.startsWith("uid=\\20alice,"),
+        "leading space not escaped in: " + rendered);
+  }
+
+  @Test
+  void username_with_trailing_space_is_escaped() {
+    // RFC 4514: trailing space must be escaped.
+    String rendered = realm.callExpandDnTemplate(DN_TEMPLATE, "alice ");
+    assertTrue(rendered.contains("alice\\20,"),
+        "trailing space not escaped in: " + rendered);
+  }
+
+  @Test
+  void null_input_yields_empty_substitution() {
+    String rendered = realm.callExpandDnTemplate(DN_TEMPLATE, null);
+    assertEquals("uid=,ou=users,dc=example,dc=com", rendered);
+  }
+
+  @Test
+  void korean_username_passes_through_unchanged() {
+    // Non-ASCII printable characters are not RFC 4514 metacharacters
+    // and must not be escaped.
+    String rendered = realm.callExpandDnTemplate(DN_TEMPLATE, "이종열");
+    assertEquals("uid=이종열,ou=users,dc=example,dc=com", rendered);
+  }
+
+  // -----------------------------------------------------------------------
+  // escapeAttributeValue unit tests (RFC 4514 character set coverage).
+  // -----------------------------------------------------------------------
+
+  @Test
+  void escape_comma() {
+    assertEquals("\\2C", realm.callEscapeAttributeValue(","));
+  }
+
+  @Test
+  void escape_plus() {
+    assertEquals("\\2B", realm.callEscapeAttributeValue("+"));
+  }
+
+  @Test
+  void escape_semicolon() {
+    assertEquals("\\3B", realm.callEscapeAttributeValue(";"));
+  }
+
+  @Test
+  void escape_less_than() {
+    assertEquals("\\3C", realm.callEscapeAttributeValue("<"));
+  }
+
+  @Test
+  void escape_greater_than() {
+    assertEquals("\\3E", realm.callEscapeAttributeValue(">"));
+  }
+
+  @Test
+  void escape_backslash() {
+    assertEquals("\\5C", realm.callEscapeAttributeValue("\\"));
+  }
+
+  @Test
+  void escape_double_quote() {
+    assertEquals("\\22", realm.callEscapeAttributeValue("\""));
+  }
+
+  @Test
+  void escape_null_character() {
+    assertEquals("\\00", realm.callEscapeAttributeValue("\u0000"));
+  }
+
+  @Test
+  void escape_result_not_null_for_nonnull_input() {
+    assertNotNull(realm.callEscapeAttributeValue("any value"));
+  }
+
+  @Test
+  void plain_alphanumeric_unchanged() {
+    assertEquals("alice123", realm.callEscapeAttributeValue("alice123"));
+  }
+
+  // -----------------------------------------------------------------------
+  // Helper
+  // -----------------------------------------------------------------------
+
+  /**
+   * Counts occurrences of {@code ch} that are NOT preceded by a backslash.
+   * Sufficient for asserting "no unescaped metacharacter" in RFC 4514 DNs
+   * produced by this codebase (which always uses a single leading backslash).
+   */
+  private static int countUnescaped(String s, char ch) {
+    int count = 0;
+    for (int i = 0; i < s.length(); i++) {
+      if (s.charAt(i) != ch) {
+        continue;
+      }
+      if (i >= 1 && s.charAt(i - 1) == '\\') {
+        continue;
+      }
+      count++;
+    }
+    return count;
+  }
+}
diff --git 
a/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapRealmFilterInjectionTest.java
 
b/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapRealmFilterInjectionTest.java
new file mode 100644
index 0000000000..a844d604fa
--- /dev/null
+++ 
b/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapRealmFilterInjectionTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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.realm;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * End-to-end style tests verifying that user-controlled values flowing through
+ * {@link LdapRealm#expandFilterTemplate(String, String)} cannot inject LDAP
+ * filter metacharacters. Each known PoC payload from the security report must
+ * leave the rendered filter free of unescaped {@code (}, {@code )}, or
+ * {@code *} characters that originated in the user input.
+ */
+class LdapRealmFilterInjectionTest {
+
+  // The default member substitution token used by Zeppelin's LdapRealm.
+  private static final String TEMPLATE = "(&(objectclass=person)(uid={0}))";
+
+  // The template "(&(objectclass=person)(uid={0}))" by itself contributes
+  // 3 '(' and 3 ')' and 0 '*'. After payload substitution, those counts must
+  // remain unchanged — any additional unescaped metacharacter must have leaked
+  // from the payload.
+  private static final int TEMPLATE_OPEN_PARENS = 3;
+  private static final int TEMPLATE_CLOSE_PARENS = 3;
+  private static final int TEMPLATE_ASTERISKS = 0;
+
+  @ParameterizedTest
+  @ValueSource(strings = {
+      ")(uid=*",
+      "admin)(|(uid=*",
+      "*",
+      "admin)(cn=a*",
+      ")(mail=*@corp.com",
+      "alice)(userPassword=*"
+  })
+  void poc_payloads_are_neutralized_in_rendered_filter(String payload) {
+    String rendered = LdapRealm.expandFilterTemplate(TEMPLATE, payload);
+
+    // After substitution the filter must keep exactly the parens and asterisks
+    // that came from the template — no extra ones from the payload.
+    assertEquals(TEMPLATE_OPEN_PARENS, countUnescaped(rendered, '('),
+        "extra unescaped '(' from payload: " + rendered);
+    assertEquals(TEMPLATE_CLOSE_PARENS, countUnescaped(rendered, ')'),
+        "extra unescaped ')' from payload: " + rendered);
+    assertEquals(TEMPLATE_ASTERISKS, countUnescaped(rendered, '*'),
+        "unescaped '*' from payload: " + rendered);
+
+    // Sanity: the escape sequences for the metacharacters must appear when the
+    // payload contained them.
+    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);
+    }
+  }
+
+  @Test
+  void normal_username_passes_through_unchanged() {
+    String rendered = LdapRealm.expandFilterTemplate(TEMPLATE, "alice");
+    assertEquals("(&(objectclass=person)(uid=alice))", rendered);
+  }
+
+  @Test
+  void null_input_yields_empty_substitution() {
+    String rendered = LdapRealm.expandFilterTemplate(TEMPLATE, null);
+    assertEquals("(&(objectclass=person)(uid=))", rendered);
+  }
+
+  @Test
+  void escape_output_for_literal_brace_input_is_substituted_once() {
+    // Sanity check that String.replace performs a single pass — a literal 
"{0}"
+    // in user input is substituted by the first match only because the
+    // substitution-token characters (`{`, `0`, `}`) are not LDAP filter
+    // metacharacters and therefore are not transformed by the escape function.
+    String rendered = LdapRealm.expandFilterTemplate(TEMPLATE, "{0}");
+    // The first {0} placeholder is replaced by the input "{0}" — net result
+    // is that one literal "{0}" appears at the substitution site.
+    assertEquals("(&(objectclass=person)(uid={0}))", rendered);
+  }
+
+  /**
+   * Counts occurrences of {@code ch} that are NOT preceded by a single
+   * backslash. The simple LDAP filter escape sequences in this codebase emit
+   * exactly one backslash before each metacharacter, so this is a sufficient
+   * heuristic for asserting "no unescaped metacharacter from user input".
+   */
+  private static int countUnescaped(String s, char ch) {
+    int count = 0;
+    for (int i = 0; i < s.length(); i++) {
+      if (s.charAt(i) != ch) {
+        continue;
+      }
+      // Treat {@code \xNN} hex escapes (e.g. "\\28") as escaped metacharacters
+      // and don't count them. The raw template literal characters that are
+      // part of e.g. "(&(...)" are counted, but those originate from the
+      // template, not the user-supplied payload.
+      if (i >= 1 && s.charAt(i - 1) == '\\') {
+        continue;
+      }
+      count++;
+    }
+    return count;
+  }
+}
diff --git 
a/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapRealmTest.java 
b/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapRealmTest.java
index 9abce28d4b..b6213cbc77 100644
--- a/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapRealmTest.java
+++ b/zeppelin-server/src/test/java/org/apache/zeppelin/realm/LdapRealmTest.java
@@ -47,7 +47,9 @@ class LdapRealmTest {
   void testGetUserDn() {
     LdapRealm realm = new LdapRealm();
 
-    // without a user search filter
+    // without a user search filter — when userDnTemplate is the bare
+    // placeholder, the principal is treated as a full DN supplied verbatim
+    // (no escape) so trailing-space inputs are preserved unchanged.
     realm.setUserSearchFilter(null);
     assertEquals("foo ", realm.getUserDn("foo "));
 
@@ -126,10 +128,13 @@ class LdapRealmTest {
     assertEquals("foo\\2B", realm.escapeAttributeValue("foo+"));
     assertEquals("foo\\5C", realm.escapeAttributeValue("foo\\"));
     assertEquals("foo\\00", realm.escapeAttributeValue("foo\u0000"));
+    // setUserSearchFilter / setGroupSearchFilter store the operator-supplied
+    // template verbatim; user-controlled values are escaped at substitution
+    // time by expandFilterTemplate, not at config time.
     realm.setUserSearchFilter("uid=<{0}>");
-    assertEquals("uid=\\3C{0}\\3E", realm.getUserSearchFilter());
+    assertEquals("uid=<{0}>", realm.getUserSearchFilter());
     realm.setUserSearchFilter("gid=\\{0}\\");
-    assertEquals("gid=\\5C{0}\\5C", realm.getUserSearchFilter());
+    assertEquals("gid=\\{0}\\", realm.getUserSearchFilter());
   }
 
   private NamingEnumeration<SearchResult> enumerationOf(BasicAttributes... 
attrs) {

Reply via email to