This is an automated email from the ASF dual-hosted git repository.

vincbeck pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 166cd335659 Add defensive validation for LDAP search filter 
configuration (#67630)
166cd335659 is described below

commit 166cd335659e2d2b9bcef56bb64dec89d6f47ae4
Author: OrbisAI Security <[email protected]>
AuthorDate: Wed Jun 3 22:16:06 2026 +0530

    Add defensive validation for LDAP search filter configuration (#67630)
    
    Add input validation for AUTH_LDAP_SEARCH_FILTER to catch
    misconfigurations early. In deployments where LDAP configuration
    is generated from Helm values, environment variables, or config
    management systems, filter validation helps fail fast on malformed
    filters and makes debugging easier.
    
    Changes:
    - Escape username in LDAP search using ldap.filter.escape_filter_chars()
    - Validate AUTH_LDAP_SEARCH_FILTER has balanced parentheses
    - Add focused test for filter construction and validation
    
    This is defensive hardening, not a vulnerability fix.
    AUTH_LDAP_SEARCH_FILTER is controlled by Airflow administrators,
    not end users or attackers.
---
 .../fab/auth_manager/security_manager/override.py  | 10 +++-
 .../auth_manager/security_manager/test_override.py | 64 ++++++++++++++++++++++
 2 files changed, 73 insertions(+), 1 deletion(-)

diff --git 
a/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
 
b/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
index faf4c2ed415..9a89b6574b3 100644
--- 
a/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
+++ 
b/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
@@ -2469,9 +2469,17 @@ class 
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
             raise ValueError("AUTH_LDAP_SEARCH must be set")
 
         # build the filter string for the LDAP search
-        # escape username to prevent LDAP injection attacks
+        # escape username to prevent LDAP filter injection
         escaped_username = ldap.filter.escape_filter_chars(username)
         if self.auth_ldap_search_filter:
+            # validate the search filter has balanced parentheses
+            _sf = self.auth_ldap_search_filter
+            if not (_sf.startswith("(") and _sf.endswith(")") and 
_sf.count("(") == _sf.count(")")):
+                raise ValueError(
+                    f"AUTH_LDAP_SEARCH_FILTER must be a valid LDAP filter with 
balanced parentheses, "
+                    f"starting with '(' and ending with ')'. Example: 
'(objectClass=person)'. "
+                    f"Got: {repr(_sf)[:100]}"
+                )
             filter_str = 
f"(&{self.auth_ldap_search_filter}({self.auth_ldap_uid_field}={escaped_username}))"
         else:
             filter_str = f"({self.auth_ldap_uid_field}={escaped_username})"
diff --git 
a/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py 
b/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py
index a7a3e620d24..76849374fe7 100644
--- 
a/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py
+++ 
b/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py
@@ -471,3 +471,67 @@ class TestFabAirflowSecurityManagerOverride:
             assert user_info["username"] == "user-123"
             assert user_info["email"] == "[email protected]"
             assert user_info["role_keys"] == ["admin-group", "viewer-group"]
+
+
+def test_ldap_search_escapes_username_and_validates_filter():
+    """Test that LDAP search properly escapes username and validates search 
filter."""
+    mock_ldap = Mock()
+    mock_ldap.SCOPE_SUBTREE = 2
+
+    def escape_chars(text):
+        # Escape backslash first, then special chars
+        result = text.replace("\\", "\\5c")
+        result = result.replace("*", "\\2a")
+        result = result.replace("(", "\\28")
+        result = result.replace(")", "\\29")
+        return result
+
+    mock_ldap.filter.escape_filter_chars = escape_chars
+    mock_con = Mock()
+    mock_con.search_s = Mock(return_value=[("cn=test,dc=example,dc=com", {})])
+
+    sm = EmptySecurityManager()
+    with (
+        mock.patch.object(
+            type(sm), "auth_ldap_search", new_callable=mock.PropertyMock, 
return_value="dc=example,dc=com"
+        ),
+        mock.patch.object(
+            type(sm),
+            "auth_ldap_search_filter",
+            new_callable=mock.PropertyMock,
+            return_value="(objectClass=person)",
+        ),
+        mock.patch.object(
+            type(sm), "auth_ldap_uid_field", new_callable=mock.PropertyMock, 
return_value="uid"
+        ),
+        mock.patch.object(
+            type(sm), "auth_ldap_firstname_field", 
new_callable=mock.PropertyMock, return_value="givenName"
+        ),
+        mock.patch.object(
+            type(sm), "auth_ldap_lastname_field", 
new_callable=mock.PropertyMock, return_value="sn"
+        ),
+        mock.patch.object(
+            type(sm), "auth_ldap_email_field", new_callable=mock.PropertyMock, 
return_value="mail"
+        ),
+        mock.patch.object(type(sm), "auth_roles_mapping", 
new_callable=mock.PropertyMock, return_value=None),
+        mock.patch.object(
+            type(sm),
+            "auth_ldap_use_nested_groups_for_roles",
+            new_callable=mock.PropertyMock,
+            return_value=False,
+        ),
+    ):
+        # Test with special characters in username - should be escaped
+        sm._search_ldap(mock_ldap, mock_con, "test*user")
+
+        # Verify the filter was constructed with escaped username
+        call_args = mock_con.search_s.call_args
+        actual_filter = call_args[0][2]
+        assert "test\\2auser" in actual_filter  # * should be escaped
+
+        # Test that invalid filter raises ValueError
+        with mock.patch.object(
+            type(sm), "auth_ldap_search_filter", 
new_callable=mock.PropertyMock, return_value="invalid"
+        ):
+            with pytest.raises(ValueError, match="AUTH_LDAP_SEARCH_FILTER"):
+                sm._search_ldap(mock_ldap, mock_con, "testuser")

Reply via email to