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) {