Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions .github/workflows/build/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ ADD .github/workflows/build/gateway-site.xml /knox-runtime/conf/gateway-site.xml
ADD .github/workflows/build/conf/topologies/knoxtoken.xml /knox-runtime/conf/topologies/knoxtoken.xml
ADD .github/workflows/build/conf/topologies/health.xml /knox-runtime/conf/topologies/health.xml
ADD .github/workflows/build/conf/topologies/knoxldap.xml /knox-runtime/conf/topologies/knoxldap.xml
ADD .github/workflows/build/conf/topologies/knoxldapcache.xml /knox-runtime/conf/topologies/knoxldapcache.xml
ADD .github/workflows/build/conf/topologies/remoteauth.xml /knox-runtime/conf/topologies/remoteauth.xml
ADD .github/workflows/build/conf/topologies/k8sauth.xml /knox-runtime/conf/topologies/k8sauth.xml
ADD .github/workflows/build/conf/topologies/knoxidf-ldap.xml /knox-runtime/conf/topologies/knoxidf-ldap.xml
Expand Down
105 changes: 105 additions & 0 deletions .github/workflows/build/conf/topologies/knoxldapcache.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<!--
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.
-->
<!--
Mirror of knoxldap.xml, but with Shiro authentication caching turned on so
the KnoxCacheManager / EhcacheShiro path is exercised end to end. Repeated
authentications of the same principal are served from the EhcacheShiro cache
after the first bind. Used by test_knox_ldap_cache.py.
-->
<topology>
<gateway>
<provider>
<role>authentication</role>
<name>ShiroProvider</name>
<enabled>true</enabled>
<param>
<name>sessionTimeout</name>
<value>30</value>
</param>
<param>
<name>main.ldapRealm</name>
<value>org.apache.knox.gateway.shirorealm.KnoxLdapRealm</value>
</param>
<param>
<name>main.ldapRealm.userDnTemplate</name>
<value>uid={0},ou=people,dc=proxy,dc=org</value>
</param>
<param>
<name>main.ldapRealm.contextFactory.url</name>
<value>ldaps://localhost:33390</value> <!-- Local Knox LDAP service (LDAPS); configured to proxy to the demo LDAP as a backend -->
</param>
<param>
<name>main.ldapRealm.contextFactory.authenticationMechanism</name>
<value>simple</value>
</param>
<!-- Wire the Knox Ehcache-backed Shiro cache manager and enable
authentication caching so repeat logins hit the cache. -->
<param>
<name>main.cacheManager</name>
<value>org.apache.knox.gateway.shirorealm.KnoxCacheManager</value>
</param>
<param>
<name>main.securityManager.cacheManager</name>
<value>$cacheManager</value>
</param>
<param>
<name>main.ldapRealm.authenticationCachingEnabled</name>
<value>true</value>
</param>
<param>
<name>urls./**</name>
<value>authcBasic</value>
</param>
</provider>
<provider>
<role>identity-assertion</role>
<name>HadoopGroupProvider</name>
<enabled>true</enabled>
<param>
<name>group.principal.mapping</name>
<value>admin=longGroupName1,longGroupName2,longGroupName3,longGroupName4</value>
</param>
<param>
<name>CENTRAL_GROUP_CONFIG_PREFIX</name>
<value>gateway.group.config.</value>
</param>
</provider>
</gateway>
<service>
<role>KNOXTOKEN</role>
<param>
<name>knoxsso.token.ttl</name>
<value>86400000</value>
</param>
</service>
<service>
<role>KNOX-AUTH-SERVICE</role>
<param>
<name>preauth.auth.header.actor.id.name</name>
<value>x-knox-actor-username</value>
</param>
<param>
<name>preauth.auth.header.actor.groups.prefix</name>
<value>x-knox-actor-groups</value>
</param>
<param>
<name>preauth.group.filter.pattern</name>
<value>[^\s]+</value>
</param>
</service>
</topology>
6 changes: 6 additions & 0 deletions .github/workflows/tests/common_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import requests
import urllib3
from requests.auth import HTTPBasicAuth

# Default timeout for HTTP calls to the gateway (self-signed TLS, CI).
KNOX_REQUEST_TIMEOUT = 30
Expand All @@ -48,6 +49,11 @@ def knox_get(url: str, **kwargs: Any) -> requests.Response:
return requests.get(url, **opts)


def basic_auth_get(url: str, username: str, password: str) -> requests.Response:
"""GET url with HTTP Basic credentials (verify off, default timeout)."""
return knox_get(url, auth=HTTPBasicAuth(username, password))


def knox_post(url: str, **kwargs: Any) -> requests.Response:
"""POST against Knox with verify=False and default timeout unless overridden."""
opts: dict[str, Any] = {"verify": False, "timeout": KNOX_REQUEST_TIMEOUT}
Expand Down
99 changes: 99 additions & 0 deletions .github/workflows/tests/test_knox_ldap_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# 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 Shiro authentication caching in KnoxLdapRealm.

Runs against the live knox + ldap docker-compose stack via the knoxldapcache
topology, whose ShiroProvider wires the Ehcache-backed Knox cache manager and
enables authentication caching:
main.cacheManager = org.apache.knox.gateway.shirorealm.KnoxCacheManager
main.securityManager.cacheManager = $cacheManager
main.ldapRealm.authenticationCachingEnabled = true

Guarantees:
* caching - repeated authentications of the same principal all succeed
(200); the first bind populates the EhcacheShiro cache and the
subsequent ones are served through it, so caching must not
break the auth result. (Cache hit/miss is confirmed separately
by inspecting the knox logs for KnoxCacheManager messages.)
* security - a wrong password is still rejected (401), so caching keyed on
the principal never authenticates bad credentials; and a
DN-injection username is still rejected (401), so the RFC 4514
escaping remains effective with caching enabled.
"""

import unittest

from common_utils import basic_auth_get, gateway_base_url

# Repeat count for the cache round-trip: first request populates the cache,
# the rest exercise the cached path.
REPEAT = 5


class TestKnoxLdapCache(unittest.TestCase):
"""Auth caching works and does not weaken credential or DN-injection checks."""

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

def test_repeated_auth_is_served_and_succeeds(self):
"""Repeated logins of the same user all succeed with caching enabled."""
for attempt in range(REPEAT):
with self.subTest(attempt=attempt):
response = basic_auth_get(
self.topology_url, "guest", "guest-password"
)
self.assertEqual(
response.status_code,
200,
f"guest attempt {attempt} should authenticate; "
f"got {response.status_code}",
)
self.assertEqual(
response.headers.get("x-knox-actor-username"), "guest"
)

def test_wrong_password_still_rejected(self):
"""Caching is keyed on the principal but must not accept a bad password."""
# Warm the cache with a valid login first.
warm = basic_auth_get(self.topology_url, "guest", "guest-password")
self.assertEqual(warm.status_code, 200)
# Same principal, wrong password: must be rejected despite a cache entry.
response = basic_auth_get(self.topology_url, "guest", "wrong-password")
self.assertEqual(
response.status_code,
401,
f"wrong password must be rejected; got {response.status_code}",
)
self.assertNotIn("x-knox-actor-username", response.headers)

def test_dn_injection_still_rejected_with_caching(self):
"""DN-escaping stays effective with caching on: injection user is 401."""
response = basic_auth_get(
self.topology_url, "guest,ou=people,dc=proxy,dc=org", "guest-password"
)
self.assertEqual(
response.status_code,
401,
f"injection username must be rejected; got {response.status_code}",
)
self.assertNotIn("x-knox-actor-username", response.headers)


if __name__ == "__main__":
unittest.main()
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
Loading
Loading