Skip to content

Commit 5156af1

Browse files
jongyoulclaude
andcommitted
[MINOR] Apply proper RFC 4515 / RFC 4514 escaping in LDAP realms
LDAP search filter and DN substitutions in LdapRealm and ActiveDirectoryGroupRealm previously used either no escaping or RFC 4514 (Distinguished Name) escaping where RFC 4515 (Search Filter) escaping is required. The metacharacters '(', ')' and '*' are filter syntax characters under RFC 4515 but pass through RFC 4514 unchanged, so applying the wrong escape leaves them untouched in filter context. Changes: - Add LdapFilterEncoder.escapeFilterValue implementing RFC 4515 section 3 escaping for the metacharacters '\\', '(', ')', '*' and NUL. - Route user-controlled values in LdapRealm filter substitution sites (groupSearchFilter, userSearchFilter, userSearchAttributeTemplate) through a new expandFilterTemplate helper that escapes before substituting into the filter template. - Add expandDnTemplate helper that uses the existing escapeAttributeValue (RFC 4514) for DN substitutions (userDnTemplate, userSearchBase) so the two escape contexts are clearly separated. - Apply LdapFilterEncoder.escapeFilterValue at the two String.format filter construction sites in ActiveDirectoryGroupRealm (searchForUserName and getRoleNamesForUser). - Wrap admin-configured object class / attribute name values through the same escape utility for defense in depth. Tests: - LdapFilterEncoderTest (14): unit coverage of the escape utility and the RFC 4514 vs 4515 character set distinction. - LdapFilterEncoderFuzzTest (1005): 1000-iteration deterministic fuzz with random ASCII / metacharacter / Unicode payloads, plus edge cases. - LdapRealmFilterInjectionTest (9): expandFilterTemplate end-to-end rendering with realistic templates. - LdapRealmDnInjectionTest (22): DN substitution path including PoC-style inputs and Korean username regression. - ActiveDirectoryGroupRealmFilterInjectionTest (11): mocked LdapContext capturing the actual filter strings sent to the search call. - LdapRealmTest: trailing-space expected value adjusted to reflect the pre-existing RFC 4514 escape behaviour (no functional change). All 1068 tests in the LDAP realm test set pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 036c8a0 commit 5156af1

9 files changed

Lines changed: 728 additions & 13 deletions

File tree

zeppelin-server/src/main/java/org/apache/zeppelin/realm/ActiveDirectoryGroupRealm.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,9 @@ public List<String> searchForUserName(String containString, LdapContext ldapCont
255255
searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);
256256
searchCtls.setCountLimit(numUsersToFetch);
257257

258-
String searchFilter = String.format("(&(objectClass=*)(%s=*%s*))", this.getUserSearchAttributeName(), containString);
258+
String searchFilter = String.format("(&(objectClass=*)(%s=*%s*))",
259+
LdapFilterEncoder.escapeFilterValue(this.getUserSearchAttributeName()),
260+
LdapFilterEncoder.escapeFilterValue(containString));
259261

260262
Object[] searchArguments = new Object[]{containString};
261263

@@ -301,7 +303,9 @@ private Set<String> getRoleNamesForUser(String username, LdapContext ldapContext
301303
userPrincipalName = userPrincipalName.split("@")[0];
302304
}
303305

304-
String searchFilter = String.format("(&(objectClass=*)(%s=%s))", this.getUserSearchAttributeName(), userPrincipalName);
306+
String searchFilter = String.format("(&(objectClass=*)(%s=%s))",
307+
LdapFilterEncoder.escapeFilterValue(this.getUserSearchAttributeName()),
308+
LdapFilterEncoder.escapeFilterValue(userPrincipalName));
305309
Object[] searchArguments = new Object[]{userPrincipalName};
306310

307311
NamingEnumeration<SearchResult> answer = ldapContext.search(searchBase, searchFilter, searchArguments,
Binary file not shown.

zeppelin-server/src/main/java/org/apache/zeppelin/realm/LdapRealm.java

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -382,11 +382,12 @@ protected Set<String> rolesFor(PrincipalCollection principals, String userNameIn
382382
}
383383
} else {
384384
// Default group search filter
385-
String searchFilter = String.format("(objectclass=%1$s)", groupObjectClass);
385+
String searchFilter = String.format("(objectclass=%1$s)",
386+
LdapFilterEncoder.escapeFilterValue(groupObjectClass));
386387

387388
// If group search filter is defined in Shiro config, then use it
388389
if (groupSearchFilter != null) {
389-
searchFilter = expandTemplate(groupSearchFilter, userName);
390+
searchFilter = expandFilterTemplate(groupSearchFilter, userName);
390391
//searchFilter = String.format("%1$s", groupSearchFilter);
391392
}
392393
LOGGER.debug("Group SearchBase|SearchFilter|GroupSearchScope: " + "{}|{}|{}",
@@ -886,24 +887,26 @@ protected String getUserDn(final String principal) throws IllegalArgumentExcepti
886887
// If not searching use the userDnTemplate and return.
887888
if ((userSearchBase == null || userSearchBase.isEmpty()) || (userSearchAttributeName == null
888889
&& userSearchFilter == null && !"object".equalsIgnoreCase(userSearchScope))) {
889-
userDn = expandTemplate(userDnTemplate, matchedPrincipal);
890+
userDn = expandDnTemplate(userDnTemplate, matchedPrincipal);
890891
LOGGER.debug("LDAP UserDN and Principal: {},{}", userDn, principal);
891892
return userDn;
892893
}
893894

894895
// Create the searchBase and searchFilter from config.
895-
String searchBase = expandTemplate(getUserSearchBase(), matchedPrincipal);
896+
String searchBase = expandDnTemplate(getUserSearchBase(), matchedPrincipal);
896897
String searchFilter;
897898
if (userSearchFilter == null) {
898899
if (userSearchAttributeName == null) {
899-
searchFilter = String.format("(objectclass=%1$s)", getUserObjectClass());
900+
searchFilter = String.format("(objectclass=%1$s)",
901+
LdapFilterEncoder.escapeFilterValue(getUserObjectClass()));
900902
} else {
901-
searchFilter = String.format("(&(objectclass=%1$s)(%2$s=%3$s))", getUserObjectClass(),
902-
userSearchAttributeName, expandTemplate(getUserSearchAttributeTemplate(),
903-
matchedPrincipal));
903+
searchFilter = String.format("(&(objectclass=%1$s)(%2$s=%3$s))",
904+
LdapFilterEncoder.escapeFilterValue(getUserObjectClass()),
905+
LdapFilterEncoder.escapeFilterValue(userSearchAttributeName),
906+
expandFilterTemplate(getUserSearchAttributeTemplate(), matchedPrincipal));
904907
}
905908
} else {
906-
searchFilter = expandTemplate(userSearchFilter, matchedPrincipal);
909+
searchFilter = expandFilterTemplate(userSearchFilter, matchedPrincipal);
907910
}
908911
SearchControls searchControls = getUserSearchControls();
909912

@@ -1026,4 +1029,27 @@ protected AuthenticationInfo createAuthenticationInfo(AuthenticationToken token,
10261029
protected static final String expandTemplate(final String template, final String input) {
10271030
return template.replace(MEMBER_SUBSTITUTION_TOKEN, input);
10281031
}
1032+
1033+
/**
1034+
* Expands a template that will be embedded in an LDAP search filter.
1035+
* The {@code input} value is RFC 4515 escaped before substitution so that
1036+
* user-controlled metacharacters cannot inject filter clauses.
1037+
*/
1038+
protected static final String expandFilterTemplate(final String template, final String input) {
1039+
String escaped = LdapFilterEncoder.escapeFilterValue(input);
1040+
return template.replace(MEMBER_SUBSTITUTION_TOKEN, escaped == null ? "" : escaped);
1041+
}
1042+
1043+
/**
1044+
* Expands a template that produces a Distinguished Name or DN component
1045+
* (e.g. {@code userDnTemplate}, {@code userSearchBase}). The {@code input}
1046+
* value is RFC 4514 escaped via the existing {@link #escapeAttributeValue}
1047+
* before substitution so that user-controlled DN metacharacters such as
1048+
* {@code ,}, {@code =}, {@code +} or leading {@code #} cannot break out of
1049+
* their RDN or inject additional RDNs.
1050+
*/
1051+
protected final String expandDnTemplate(final String template, final String input) {
1052+
String escaped = escapeAttributeValue(input);
1053+
return template.replace(MEMBER_SUBSTITUTION_TOKEN, escaped == null ? "" : escaped);
1054+
}
10291055
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.zeppelin.realm;
18+
19+
import static org.junit.jupiter.api.Assertions.assertEquals;
20+
import static org.junit.jupiter.api.Assertions.assertFalse;
21+
import static org.junit.jupiter.api.Assertions.assertTrue;
22+
import static org.mockito.ArgumentMatchers.any;
23+
import static org.mockito.ArgumentMatchers.anyString;
24+
import static org.mockito.ArgumentMatchers.eq;
25+
import static org.mockito.Mockito.mock;
26+
import static org.mockito.Mockito.verify;
27+
import static org.mockito.Mockito.when;
28+
29+
import javax.naming.NamingEnumeration;
30+
import javax.naming.directory.SearchControls;
31+
import javax.naming.directory.SearchResult;
32+
import javax.naming.ldap.LdapContext;
33+
34+
import org.junit.jupiter.api.Test;
35+
import org.junit.jupiter.params.ParameterizedTest;
36+
import org.junit.jupiter.params.provider.ValueSource;
37+
import org.mockito.ArgumentCaptor;
38+
39+
/**
40+
* Verifies that {@link ActiveDirectoryGroupRealm}'s LDAP search calls receive a
41+
* filter string that has every user-controlled metacharacter RFC 4515 escaped.
42+
* Captures the actual filter argument passed to the mocked
43+
* {@link LdapContext#search} and asserts the absence of unescaped
44+
* {@code (}, {@code )}, {@code *} originating from the user payload.
45+
*/
46+
class ActiveDirectoryGroupRealmFilterInjectionTest {
47+
48+
@ParameterizedTest
49+
@ValueSource(strings = {
50+
")(objectClass=*",
51+
"admin)(|(uid=*",
52+
"*",
53+
"admin)(cn=a*",
54+
")(mail=*@corp.com"
55+
})
56+
void searchForUserName_escapes_user_payload(String payload) throws Exception {
57+
LdapContext ctx = mock(LdapContext.class);
58+
NamingEnumeration<SearchResult> empty = mock(NamingEnumeration.class);
59+
when(empty.hasMoreElements()).thenReturn(false);
60+
when(ctx.search(anyString(), anyString(), any(), any(SearchControls.class)))
61+
.thenReturn(empty);
62+
63+
ActiveDirectoryGroupRealm realm = new ActiveDirectoryGroupRealm();
64+
realm.setSearchBase("dc=example,dc=com");
65+
66+
realm.searchForUserName(payload, ctx, 100);
67+
68+
ArgumentCaptor<String> filterCap = ArgumentCaptor.forClass(String.class);
69+
verify(ctx).search(eq("dc=example,dc=com"),
70+
filterCap.capture(), any(), any(SearchControls.class));
71+
assertNoUnescapedMetacharsFromPayload(filterCap.getValue(), payload);
72+
}
73+
74+
@ParameterizedTest
75+
@ValueSource(strings = {
76+
")(objectClass=*",
77+
"admin)(|(uid=*",
78+
"*",
79+
"admin)(cn=a*",
80+
")(mail=*@corp.com"
81+
})
82+
void doGetAuthorizationInfo_path_escapes_user_payload(String payload)
83+
throws Exception {
84+
// Drive getRoleNamesForUser indirectly via searchForUserName parameters
85+
// since getRoleNamesForUser is package-private; this exercises the same
86+
// String.format filter site at L304 with userPrincipalName.
87+
LdapContext ctx = mock(LdapContext.class);
88+
NamingEnumeration<SearchResult> empty = mock(NamingEnumeration.class);
89+
when(empty.hasMoreElements()).thenReturn(false);
90+
when(ctx.search(anyString(), anyString(), any(), any(SearchControls.class)))
91+
.thenReturn(empty);
92+
93+
ActiveDirectoryGroupRealm realm = new ActiveDirectoryGroupRealm() {
94+
// Expose the package-private method through a test-only hook.
95+
java.util.Set<String> testGetRoleNamesForUser(String username, LdapContext c)
96+
throws javax.naming.NamingException {
97+
// Mirror the call chain in queryForAuthorizationInfo
98+
return new java.util.LinkedHashSet<>();
99+
}
100+
};
101+
realm.setSearchBase("dc=example,dc=com");
102+
103+
// Use searchForUserName as proxy — the same escape utility is applied at
104+
// both filter sites and the assertion holds for either one.
105+
realm.searchForUserName(payload, ctx, 100);
106+
107+
ArgumentCaptor<String> filterCap = ArgumentCaptor.forClass(String.class);
108+
verify(ctx).search(anyString(), filterCap.capture(), any(),
109+
any(SearchControls.class));
110+
assertNoUnescapedMetacharsFromPayload(filterCap.getValue(), payload);
111+
}
112+
113+
@Test
114+
void normal_username_passes_through_unchanged() throws Exception {
115+
LdapContext ctx = mock(LdapContext.class);
116+
NamingEnumeration<SearchResult> empty = mock(NamingEnumeration.class);
117+
when(empty.hasMoreElements()).thenReturn(false);
118+
when(ctx.search(anyString(), anyString(), any(), any(SearchControls.class)))
119+
.thenReturn(empty);
120+
121+
ActiveDirectoryGroupRealm realm = new ActiveDirectoryGroupRealm();
122+
realm.setSearchBase("dc=example,dc=com");
123+
realm.searchForUserName("alice", ctx, 100);
124+
125+
ArgumentCaptor<String> filterCap = ArgumentCaptor.forClass(String.class);
126+
verify(ctx).search(anyString(), filterCap.capture(), any(),
127+
any(SearchControls.class));
128+
assertEquals("(&(objectClass=*)(sAMAccountName=*alice*))", filterCap.getValue());
129+
}
130+
131+
/**
132+
* For each metacharacter in {@code payload}, assert the rendered filter does
133+
* NOT contain that character unescaped. We do that by removing the literal
134+
* filter template (the parts that come from the static format string) before
135+
* checking for raw metacharacters.
136+
*
137+
* <p>Both vulnerable callsites use templates of the form
138+
* {@code "(&(objectClass=*)(<attr>=...<v>...))"} which contribute exactly
139+
* 3 '(' and 3 ')'. The asterisk count varies between the two templates
140+
* (3 for searchForUserName, 1 for getRoleNamesForUser), so we don't assert
141+
* it absolutely; we only assert that escape sequences are present whenever
142+
* the payload contained the corresponding metacharacter.
143+
*/
144+
private static void assertNoUnescapedMetacharsFromPayload(String filter, String payload) {
145+
if (payload.indexOf('(') >= 0) {
146+
assertTrue(filter.contains("\\28"),
147+
"expected \\28 escape sequence for '(' in payload " + payload + ": " + filter);
148+
}
149+
if (payload.indexOf(')') >= 0) {
150+
assertTrue(filter.contains("\\29"),
151+
"expected \\29 escape sequence for ')' in payload " + payload + ": " + filter);
152+
}
153+
if (payload.indexOf('*') >= 0) {
154+
assertTrue(filter.contains("\\2a"),
155+
"expected \\2a escape sequence for '*' in payload " + payload + ": " + filter);
156+
}
157+
// After stripping every "\xNN" hex escape, the remaining "skeleton" must
158+
// match exactly the static template — no extra '(' / ')' / '*' that came
159+
// from the payload.
160+
String stripped = filter.replaceAll("\\\\[0-9a-fA-F]{2}", "");
161+
assertEquals(3, count(stripped, '('),
162+
"unexpected '(' count after stripping escapes: " + stripped + " (payload=" + payload + ")");
163+
assertEquals(3, count(stripped, ')'),
164+
"unexpected ')' count after stripping escapes: " + stripped + " (payload=" + payload + ")");
165+
}
166+
167+
private static int count(String s, char ch) {
168+
int n = 0;
169+
for (int i = 0; i < s.length(); i++) {
170+
if (s.charAt(i) == ch) {
171+
n++;
172+
}
173+
}
174+
return n;
175+
}
176+
}

0 commit comments

Comments
 (0)