Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions .github/workflows/tests/test_knox_ldap_dn_injection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# 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.

"""Integration tests for LDAP DN-injection hardening in KnoxLdapRealm.

Runs against the live knox + ldap docker-compose stack via the knoxldap
topology, whose ShiroProvider binds with:
main.ldapRealm.userDnTemplate = uid={0},ou=people,dc=proxy,dc=org

Two guarantees:
* regression - legitimate demo-LDAP users still authenticate (HTTP 200);
* injection - usernames carrying DN metacharacters do not authenticate
(HTTP 401), because the username is RFC 4514-escaped before
it is substituted into the bind DN, so it cannot alter the
DN structure and resolves to no real directory entry.
"""

import unittest

from requests.auth import HTTPBasicAuth

from common_utils import gateway_base_url, knox_get

# Usernames whose DN metacharacters (',', '=', '*', '(', ')') would rewrite or
# widen the bind DN uid={0},ou=people,dc=proxy,dc=org if left unescaped. After
# RFC 4514 escaping each is a single literal uid value matching no entry, so the
# bind fails and Knox returns 401. None of these must ever authenticate.
INJECTION_USERNAMES = [
"guest,ou=people,dc=proxy,dc=org",
"admin,ou=people,dc=proxy,dc=org",
"guest,ou=admin",
"*",
"guest)(uid=*",
"uid=admin,ou=people,dc=proxy,dc=org",
]


class TestKnoxLdapDnInjection(unittest.TestCase):
"""Valid LDAP auth still works; DN-injection usernames are rejected."""

def setUp(self):
self.base_url = gateway_base_url()
# Topology name derives from the filename knoxldap.xml.
self.topology_url = self.base_url + "gateway/knoxldap/auth/api/v1/pre"

def test_valid_guest_authenticates(self):
"""Regression: a legitimate user still binds and authenticates (200)."""
response = knox_get(
self.topology_url,
auth=HTTPBasicAuth("guest", "guest-password"),
)
self.assertEqual(
response.status_code,
200,
f"guest should authenticate; got {response.status_code}",
)
self.assertEqual(response.headers.get("x-knox-actor-username"), "guest")

def test_valid_admin_authenticates(self):
"""Regression: a second legitimate user still authenticates (200)."""
response = knox_get(
self.topology_url,
auth=HTTPBasicAuth("admin", "admin-password"),
)
self.assertEqual(
response.status_code,
200,
f"admin should authenticate; got {response.status_code}",
)
self.assertEqual(response.headers.get("x-knox-actor-username"), "admin")

def test_dn_injection_usernames_are_rejected(self):
"""Injection: DN-metacharacter usernames must not authenticate (401)."""
for username in INJECTION_USERNAMES:
with self.subTest(username=username):
response = knox_get(
self.topology_url,
auth=HTTPBasicAuth(username, "guest-password"),
)
self.assertEqual(
response.status_code,
401,
f"injection username {username!r} must be rejected; "
f"got {response.status_code}",
)
self.assertNotIn("x-knox-actor-username", response.headers)


if __name__ == "__main__":
unittest.main()
16 changes: 16 additions & 0 deletions gateway-provider-security-shiro/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-web</artifactId>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-cache</artifactId>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-crypto-core</artifactId>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-crypto-hash</artifactId>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-lang</artifactId>
</dependency>

<dependency>
<groupId>org.ehcache</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@
import org.apache.knox.gateway.ShiroMessages;
import org.apache.knox.gateway.ehcache.EhcacheShiro;
import org.apache.knox.gateway.i18n.messages.MessagesFactory;
import org.apache.shiro.ShiroException;
import org.apache.shiro.lang.ShiroException;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheException;
import org.apache.shiro.io.ResourceUtils;
import org.apache.shiro.util.Destroyable;
import org.apache.shiro.util.Initializable;
import org.apache.shiro.lang.io.ResourceUtils;
import org.apache.shiro.lang.util.Destroyable;
import org.apache.shiro.lang.util.Initializable;
import org.ehcache.CacheManager;
import org.ehcache.StateTransitionException;
import org.ehcache.Status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,15 @@
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.crypto.hash.DefaultHashService;
import org.apache.shiro.crypto.hash.Hash;
import org.apache.shiro.crypto.hash.HashRequest;
import org.apache.shiro.crypto.hash.HashService;
import org.apache.shiro.crypto.SecureRandomNumberGenerator;
import org.apache.shiro.crypto.hash.SimpleHash;
import org.apache.shiro.realm.ldap.DefaultLdapRealm;
import org.apache.shiro.realm.ldap.LdapContextFactory;
import org.apache.shiro.realm.ldap.LdapUtils;
import org.apache.shiro.subject.MutablePrincipalCollection;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.util.StringUtils;
import org.apache.shiro.lang.util.ByteSource;
import org.apache.shiro.lang.util.StringUtils;

import javax.naming.AuthenticationException;
import javax.naming.Context;
Expand All @@ -61,6 +60,7 @@
import javax.naming.ldap.LdapName;
import javax.naming.ldap.PagedResultsControl;
import javax.naming.ldap.PagedResultsResponseControl;
import javax.naming.ldap.Rdn;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
Expand Down Expand Up @@ -151,6 +151,15 @@ public class KnoxLdapRealm extends DefaultLdapRealm {

private static final String HASHING_ALGORITHM = "SHA-256";

/*
* KNOX-3421: Shiro 2.x's DefaultHashService applies a large per-algorithm default
* iteration count (e.g. 50000 for SHA-256), whereas HashedCredentialsMatcher still
* defaults to a single iteration. To keep the credential round-trip consistent under
* the SHA-256 scheme, the stored hash is computed here with an explicit salt and this
* pinned iteration count, which matches the matcher configured in the constructor.
*/
private static final int HASHING_ITERATIONS = 1;

static {
SUBTREE_SCOPE.setSearchScope(SearchControls.SUBTREE_SCOPE);
ONELEVEL_SCOPE.setSearchScope(SearchControls.ONELEVEL_SCOPE);
Expand Down Expand Up @@ -186,10 +195,9 @@ public class KnoxLdapRealm extends DefaultLdapRealm {
private String userSearchAttributeName;
private String userObjectClass = "person";

private HashService hashService = new DefaultHashService();

public KnoxLdapRealm() {
HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher(HASHING_ALGORITHM);
credentialsMatcher.setHashIterations(HASHING_ITERATIONS);
setCredentialsMatcher(credentialsMatcher);
}

Expand Down Expand Up @@ -258,7 +266,7 @@ private Set<String> rolesFor(PrincipalCollection principals, final String userNa
String userDn;
if (userSearchAttributeName == null || userSearchAttributeName.isEmpty()) {
// memberAttributeValuePrefix and memberAttributeValueSuffix were computed from memberAttributeValueTemplate
userDn = memberAttributeValuePrefix + userName + memberAttributeValueSuffix;
userDn = memberAttributeValuePrefix + escapeDnValue(userName) + memberAttributeValueSuffix;
} else {
userDn = getUserDn(userName);
}
Expand Down Expand Up @@ -684,13 +692,13 @@ protected String getUserDn( final String principal ) throws IllegalArgumentExcep
( userSearchAttributeName == null &&
userSearchFilter == null &&
!"object".equalsIgnoreCase( userSearchScope ) ) ) {
userDn = expandTemplate( userDnTemplate, matchedPrincipal );
userDn = expandTemplate( userDnTemplate, matchedPrincipal, EscapeMode.DN );
LOG.computedUserDn( userDn, principal );
return userDn;
}

// Create the searchBase and searchFilter from config.
String searchBase = expandTemplate( getUserSearchBase(), matchedPrincipal );
String searchBase = expandTemplate( getUserSearchBase(), matchedPrincipal, EscapeMode.DN );
String searchFilter;
if ( userSearchFilter == null ) {
if ( userSearchAttributeName == null ) {
Expand All @@ -700,10 +708,10 @@ protected String getUserDn( final String principal ) throws IllegalArgumentExcep
"(&(objectclass=%1$s)(%2$s=%3$s))",
getUserObjectClass(),
userSearchAttributeName,
expandTemplate(getUserSearchAttributeTemplate(), matchedPrincipal, true));
expandTemplate(getUserSearchAttributeTemplate(), matchedPrincipal, EscapeMode.FILTER));
}
} else {
searchFilter = expandTemplate(userSearchFilter, matchedPrincipal, true);
searchFilter = expandTemplate(userSearchFilter, matchedPrincipal, EscapeMode.FILTER);
}
SearchControls searchControls = getUserSearchControls();

Expand Down Expand Up @@ -744,16 +752,15 @@ protected String getUserDn( final String principal ) throws IllegalArgumentExcep

@Override
protected AuthenticationInfo createAuthenticationInfo(AuthenticationToken token, Object ldapPrincipal, Object ldapCredentials, LdapContext ldapContext) throws NamingException {
HashRequest.Builder builder = new HashRequest.Builder();
Hash credentialsHash = hashService.computeHash(builder.setSource(token.getCredentials()).setAlgorithmName(HASHING_ALGORITHM).build());
return new SimpleAuthenticationInfo(token.getPrincipal(), credentialsHash.toHex(), credentialsHash.getSalt(), getName());
final ByteSource credentialsSalt = new SecureRandomNumberGenerator().nextBytes();
final SimpleHash credentialsHash = new SimpleHash(HASHING_ALGORITHM, token.getCredentials(), credentialsSalt, HASHING_ITERATIONS);
return new SimpleAuthenticationInfo(token.getPrincipal(), credentialsHash.toHex(), credentialsSalt, getName());
}

private static String expandTemplate(final String template, final Matcher input) {
return expandTemplate(template, input, false);
}
/** How a substituted template value must be escaped for its target context. */
private enum EscapeMode { NONE, FILTER, DN }
Comment thread
moresandeep marked this conversation as resolved.
Outdated

private static String expandTemplate( final String template, final Matcher input, final boolean escapeForLdapFilter ) {
private static String expandTemplate( final String template, final Matcher input, final EscapeMode escapeMode ) {
String output = template;
Matcher matcher = TEMPLATE_PATTERN.matcher( output );
while( matcher.find() ) {
Expand All @@ -762,8 +769,10 @@ private static String expandTemplate( final String template, final Matcher input
String lookupValue = input.group( lookupIndex );
if (lookupValue == null) {
lookupValue = "";
} else if (escapeForLdapFilter) {
} else if (escapeMode == EscapeMode.FILTER) {
lookupValue = escapeLdapSearchFilterValue(lookupValue);
} else if (escapeMode == EscapeMode.DN) {
lookupValue = escapeDnValue(lookupValue);
Comment thread
moresandeep marked this conversation as resolved.
}
// quoteReplacement is required: replaceFirst treats '\' and '$' in the replacement specially
output = matcher.replaceFirst(Matcher.quoteReplacement(lookupValue));
Expand Down Expand Up @@ -802,4 +811,13 @@ private static String escapeLdapSearchFilterValue(final String value) {
return sb.toString();
}

// RFC 4514 DN-value escaping (escapes ',', '=', '+', '"', '\\', '<', '>', ';',
// leading/trailing space, and a leading '#'), preventing DN injection.
static String escapeDnValue(final String value) {
if (value == null) {
return null;
}
return Rdn.escapeValue(value);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,9 @@
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.crypto.hash.DefaultHashService;
import org.apache.shiro.crypto.hash.Hash;
import org.apache.shiro.crypto.hash.HashRequest;
import org.apache.shiro.crypto.hash.HashService;
import org.apache.shiro.crypto.SecureRandomNumberGenerator;
import org.apache.shiro.crypto.hash.SimpleHash;
import org.apache.shiro.lang.util.ByteSource;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
Expand Down Expand Up @@ -82,21 +81,29 @@
*/
public class KnoxPamRealm extends AuthorizingRealm {
private static final String HASHING_ALGORITHM = "SHA-256";
/*
* KNOX-3421: Shiro 2.x's DefaultHashService applies a large per-algorithm default
* iteration count (e.g. 50000 for SHA-256), whereas HashedCredentialsMatcher still
* defaults to a single iteration. To keep the credential round-trip consistent under
* the SHA-256 scheme, the stored hash is computed here with an explicit salt and this
* pinned iteration count, which matches the matcher configured in the constructor.
*/
private static final int HASHING_ITERATIONS = 1;
private static final String SUBJECT_USER_ROLES = "subject.userRoles";
private static final String SUBJECT_USER_GROUPS = "subject.userGroups";

private static final AuditService auditService = AuditServiceFactory.getAuditService();
private static final Auditor auditor = auditService.getAuditor(AuditConstants.DEFAULT_AUDITOR_NAME,
AuditConstants.KNOX_SERVICE_NAME, AuditConstants.KNOX_COMPONENT_NAME);

private final HashService hashService = new DefaultHashService();
private final KnoxShiroMessages shiroLog = MessagesFactory.get(KnoxShiroMessages.class);
private final GatewayMessages gatewayLog = MessagesFactory.get(GatewayMessages.class);

private String service;

public KnoxPamRealm() {
HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher(HASHING_ALGORITHM);
credentialsMatcher.setHashIterations(HASHING_ITERATIONS);
setCredentialsMatcher(credentialsMatcher);
}

Expand Down Expand Up @@ -147,18 +154,13 @@ protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
}
}

HashRequest hashRequest = new HashRequest.Builder()
.setSource(token.getCredentials())
.setAlgorithmName(HASHING_ALGORITHM)
.build();
Hash credentialsHash = hashService.computeHash(hashRequest);
return createAuthenticationInfo(token, new UnixUserPrincipal(user));
}

/* Coverity Scan CID 1361684 */
if (credentialsHash == null) {
handleAuthFailure(token, "Failed to compute hash", null);
}
return new SimpleAuthenticationInfo(new UnixUserPrincipal(user), credentialsHash.toHex(),
credentialsHash.getSalt(), getName());
protected AuthenticationInfo createAuthenticationInfo(AuthenticationToken token, Object principal) {
final ByteSource credentialsSalt = new SecureRandomNumberGenerator().nextBytes();
final SimpleHash credentialsHash = new SimpleHash(HASHING_ALGORITHM, token.getCredentials(), credentialsSalt, HASHING_ITERATIONS);
return new SimpleAuthenticationInfo(principal, credentialsHash.toHex(), credentialsSalt, getName());
}

private void handleAuthFailure(AuthenticationToken token, String errorMessage, Exception e) {
Expand Down
Loading
Loading