For context: I am developing a web application where users need to authenticate to view internal documents. I neither need any detailed info on users nor special permission management, two states are sufficient: Either a session belongs to an authenticated user (→ documents can be accessed) or it does not (→ documents cannot be accessed). A user authenticates by providing a username and a password, which I want to check against an LDAP server.
I am using Python 3.10 and the ldap3
Python library.
I am currently using the following code to authenticate a user:
#!/usr/bin/env python3
import ssl
from ldap3 import Tls, Server, Connection
from ldap3.core.exceptions import LDAPBindError, LDAPPasswordIsMandatoryError
def is_valid(username: str, password: str) -> bool:
tls_configuration = Tls(validate=ssl.CERT_REQUIRED)
server = Server("ldaps://ldap.example.com", tls=tls_configuration)
user_dn = f"cn={username},ou=ops,dc=ldap,dc=example,dc=com"
try:
with Connection(server, user=user_dn, password=password):
return True
except (LDAPBindError, LDAPPasswordIsMandatoryError):
return False
If you want to run this code, you could try using the FreeIPA's project demo LDAP server .
CERT_REQUIRED
with CERT_NONE
because the server only provides a self-signed cert (this obviously is a security flaw, but required to use this particular demo – the server I want to use uses a Let's Encrypt certificate)."ldaps://ldap.example.com"
with ldaps://ipa.demo1.freeipa.org
user_dn
with f"uid={username},cn=users,cn=accounts,dc=demo1,dc=freeipa,dc=org"
After doing so, you could try running the following commands:
>>> is_valid("admin", "Secret123")
True
>>> is_valid("admin", "Secret1234")
False
>>> is_valid("admin", "")
False
>>> is_valid("admin", None)
False
>>> is_valid("nonexistent", "Secret123")
False
Does the code above safely determine if a user has provided valid credentials?
Notably, I am concerned about the following particular aspects:
with
statement should only be executed if binding was successful and therefore returns True
without further ado. Is this safe? Or could it be possible that binding succeeds but the password provided would still be considered wrong and not sufficient to authenticate the user against the web app.user_dn = f"cn={username},ou=ops,dc=ldap,dc=example,dc=com"
uses the untrusted username
(that came directly from the web form) to build a string. That basically screams LDAP injection.Also, of course, if there is anything else unsafe about my code, I'd be happy to know what it is.
I've already searched for answers to the particular aspects. Sadly, I have found nothing definite (ie no one definitely saying something I do here is bad or good), but I wanted to provide them as a starting point for a potential answer:
ldap3
documentation of the Connection
object does mention one should use escape_rdn
when binding with an untrusted username. This is at odds with my suppositions, who's right?Is attempting to bind to the LDAP server enough to verify credentials?
From the LDAP protocol side, yes, and many systems already rely on this behavior (eg pam_ldap for Linux OS-level authentication against an LDAP server). I've never heard of any server where the bind result would be deferred until another operation.
From the ldap3 module side I'd be more worried, as in my experience initializing a Connection did not attempt to connect – much less bind – to the server until I explicitly called .bind()
(or unless I specified auto_bind=True), but if your example works then I assume that using a with
block does this correctly.
In old code (which holds a persistent connection, no 'with') I've used this, but it may be outdated:
conn = ldap3.Connection(server, raise_exceptions=True)
conn.bind()
(For some apps I use Apache as a reverse proxy and its mod_auth_ldap handles LDAP authentication for me, especially when "is authenticated" is sufficient.)
Am I opening myself up to injection attacks? If so, how to properly mitigate them?
Well, kind of, but not in a way that would be easily exploitable. The bind DN is not a free-form query – it's only a weird-looking "user name" field and it must exactly match an existing entry; you can't put wildcards in it.
(It's in the LDAP server's best interests to be strict about what the "bind" operation accepts, because it's literally the user-facing operation for logging into an LDAP server before anything else is done – it's not just a "password check" function.)
For example, if you have some users at OU=Ops and some at OU=Superops,OU=Ops, then someone could specify Foo,OU=Superops
as their username resulting in UID=Foo,OU=Superops,OU=Ops,
as the DN – but they'd still have to provide the correct password for that account anyway; they cannot trick the server into using one account's privileges while checking another account's password.
However, it's easy to avoid injection regardless. DN component values can be escaped using:
ldap3.utils.dn.escape_rdn(string)
ldap.dn.escape_dn_chars(string)
That being said, I dislike "DN template" approach for a completely different reason – its rather limited usefulness; it only works when all of your accounts are under the same OU (flat hierarchy) and only when they're named after the uid
attribute.
That may be the case for a purpose-built LDAP directory, but on a typical Microsoft Active Directory server (or, I believe, on some FreeIPA servers as well) the user account entries are named after their full name (the cn
attribute) and can be scattered across many OUs. A two-step approach is more common:
uid
attribute, or similar, and verify that you found exactly one entry; When searching, you do have to worry about LDAP filter injection attacks a bit more, as a username like foo)(uid=*
might give undesirable results. (But requiring the results to match exactly 1 entry – not "at least 1" – helps with mitigating this as well.)
Filter values can be escaped using:
ldap3.utils.conv.escape_filter_chars(string)
ldap.filter.escape_filter_chars(string)
(python-ldap also has a convenient wrapper ldap.filter.filter_format
around this, but it's basically just the_filter % tuple(map(escape_filter_chars, args))
.)
The escaping rules for filter values are different from those for RDN values, so you need to use the correct one for the specific context. But at least unlike SQL, they are exactly the same everywhere, so the functions that come with your LDAP client module will work with any server.
Is TLS properly configured?
ldap3/core/tls.py looks good to me – it uses ssl.create_default_context() when supported, loads the system default CA certificates, so no extra configuration should be needed. Although it does implement custom hostname checking instead of relying on the ssl module's check_hostname so that's a bit weird. (Perhaps the LDAP-over-TLS spec defines wildcard matching rules that are slightly incompatible with the usual HTTP-over-TLS ones.)
The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.