From e705df50ccd760197991d5228a6d9462f48d3e0a Mon Sep 17 00:00:00 2001 From: Ramazan Polat Date: Tue, 19 May 2026 04:16:18 +0300 Subject: [PATCH] sources/ldap: derive TLS SNI name from hostname, not full server URI `LDAPSource.server()` built the TLS SNI server name from the raw `server_uri`, so an `ldaps://host` URI produced an SNI name of `ldaps://host` instead of the bare hostname `host`. ldap3 forwards that as `server_hostname` to the `ssl` module, and SNI-strict servers drop the handshake -- every LDAP sync and the connectivity check fail with `SSL: UNEXPECTED_EOF_WHILE_READING`. Build each `Server` first and take the SNI name from `server.host`, the hostname ldap3 has already parsed out of the URI (scheme and port stripped). Each server in a pool now gets its own SNI name instead of all sharing the first server's raw URI. closes #7756 Co-Authored-By: Claude Opus 4.7 (1M context) --- authentik/sources/ldap/models.py | 16 +++---- authentik/sources/ldap/tests/test_models.py | 50 +++++++++++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 authentik/sources/ldap/tests/test_models.py diff --git a/authentik/sources/ldap/models.py b/authentik/sources/ldap/models.py index 05492a363a21..aff87f8142ac 100644 --- a/authentik/sources/ldap/models.py +++ b/authentik/sources/ldap/models.py @@ -242,19 +242,19 @@ def server(self, **kwargs) -> ServerPool: tls_kwargs["local_certificate_file"] = certificate_file if ciphers := CONFIG.get("ldap.tls.ciphers", None): tls_kwargs["ciphers"] = ciphers.strip() - if self.sni: - tls_kwargs["sni"] = self.server_uri.split(",", maxsplit=1)[0].strip() server_kwargs = { "get_info": ALL, "connect_timeout": LDAP_TIMEOUT, - "tls": Tls(**tls_kwargs), } server_kwargs.update(kwargs) - if "," in self.server_uri: - for server in self.server_uri.split(","): - servers.append(Server(server, **server_kwargs)) - else: - servers = [Server(self.server_uri, **server_kwargs)] + for server_uri in self.server_uri.split(","): + server = Server(server_uri.strip(), **server_kwargs) + # The TLS SNI server name must be a bare hostname. ldap3 has already + # parsed the scheme and port out of the URI into `server.host`; + # passing the raw URI (e.g. `ldaps://host`) as the SNI name breaks + # the handshake against SNI-strict servers. See #7756. + server.tls = Tls(**tls_kwargs, sni=server.host if self.sni else None) + servers.append(server) return ServerPool(servers, RANDOM, active=5, exhaust=True) def connection( diff --git a/authentik/sources/ldap/tests/test_models.py b/authentik/sources/ldap/tests/test_models.py new file mode 100644 index 000000000000..c5b5903a0b60 --- /dev/null +++ b/authentik/sources/ldap/tests/test_models.py @@ -0,0 +1,50 @@ +"""LDAP Source model tests""" + +from django.test import TestCase + +from authentik.lib.generators import generate_id +from authentik.sources.ldap.models import LDAPSource + + +class LDAPModelTests(TestCase): + """LDAP Source model tests""" + + def test_server_sni(self): + """Test that the SNI name is the bare hostname, not the full server URI""" + source = LDAPSource.objects.create( + name=generate_id(), + slug=generate_id(), + server_uri="ldaps://ldap.example.com:636", + base_dn="dc=example,dc=com", + sni=True, + ) + pool = source.server() + self.assertEqual([server.tls.sni for server in pool.servers], ["ldap.example.com"]) + + def test_server_sni_multiple(self): + """Test that each server in a pool gets its own hostname as the SNI name""" + source = LDAPSource.objects.create( + name=generate_id(), + slug=generate_id(), + server_uri="ldaps://ldap1.example.com,ldaps://ldap2.example.com:636", + base_dn="dc=example,dc=com", + sni=True, + ) + pool = source.server() + self.assertEqual( + [server.tls.sni for server in pool.servers], + ["ldap1.example.com", "ldap2.example.com"], + ) + + def test_server_sni_disabled(self): + """Test that no SNI name is set when the SNI option is disabled""" + source = LDAPSource.objects.create( + name=generate_id(), + slug=generate_id(), + server_uri="ldaps://ldap.example.com", + base_dn="dc=example,dc=com", + sni=False, + ) + pool = source.server() + for server in pool.servers: + self.assertIsNone(server.tls.sni)