Skip to content

Commit 2f1b2e6

Browse files
marevolclaude
andauthored
Improve LDAP authentication error handling and resource management (#2949)
* Improve LDAP authentication error handling and resource management This commit enhances the LDAP authentication implementation with several important improvements for robustness, security, and maintainability: ## Error Handling Improvements - Added more specific exception handling in validate(), login(), and changePassword() methods - Separated LdapConfigurationException, LdapOperationException, and generic Exception handling for better error diagnosis - Improved error logging with more descriptive messages including context information (e.g., username, operation type) ## Resource Management - Enhanced ThreadLocal cleanup in DirContextHolder.close() with try-finally block to ensure contextLocal.remove() is always called - Prevents potential ThreadLocal memory leaks in application servers - Added debug logging for context close failures ## Defensive Programming - Added null/blank checks in critical methods: * login(username, password) - validates both parameters * login(username) - validates username parameter * changePassword() - validates username and password * getSAMAccountGroupName() - validates bindDn and groupName - Returns empty results gracefully instead of failing with NPE - Added early returns for invalid input to avoid unnecessary processing ## Documentation Enhancements - Expanded JavaDoc for escapeLDAPSearchFilter() with: * Detailed character escaping reference per RFC 4515 * Security notes emphasizing LDAP injection prevention * RFC reference link for further reading - Enhanced changePassword() JavaDoc with: * HTML-formatted documentation * Detailed validation steps list * Parameter constraints clearly documented - Added null handling in escapeLDAPSearchFilter() for safety - Optimized StringBuilder initial capacity for better performance ## Minor Code Quality Improvements - Changed local variables to final where appropriate for immutability - Improved code organization with better nesting and try-catch structure - Consistent error message formatting across methods These changes improve the security, reliability, and maintainability of the LDAP authentication module without breaking existing functionality. * Fix JavaDoc to match null handling in escapeLDAPSearchFilter Updated the JavaDoc documentation for escapeLDAPSearchFilter() to accurately reflect that null input is treated as an empty string, rather than stating 'must not be null' which conflicted with the implementation. This change resolves the inconsistency between documentation and implementation, maintaining defensive programming while being clear about the method's behavior. * Add comprehensive tests for LDAP authentication improvements This commit adds extensive unit tests for the LDAP authentication improvements made in previous commits, covering: ## LDAP Injection Prevention Tests (11 tests) - Null and empty string handling - Normal input (alphanumeric, dots) - Special character escaping: * Backslash (\) → \5c * Asterisk (*) → \2a (prevents wildcard injection) * Parentheses ((), )) → \28, \29 (prevents filter injection) * Null byte (\0) → \00 - Complex injection attempt simulation - All special characters combined - Unicode character preservation - Mixed content handling ## Defensive Null/Blank Check Tests (11 tests) - getSAMAccountGroupName(): * Null bindDn handling * Blank bindDn handling (empty string and whitespace) * Null groupName handling * Blank groupName handling - changePassword(): * Null username handling * Blank username handling * Null password handling * Blank password handling * Admin disabled scenario ## Error Handling Tests (3 tests) - normalizePermissionName(): * Null input handling * Lowercase enabled behavior * Lowercase disabled behavior ## Edge Case Tests (3 tests) - getSearchRoleName() with various invalid inputs - replaceWithUnderscores() with edge cases - escapeLDAPSearchFilter() with Unicode characters Total: 28 new comprehensive unit tests added Test Coverage: - Security: LDAP injection prevention - Robustness: Null/blank input handling - Correctness: Expected behavior validation - Edge cases: Boundary condition testing All tests follow the existing test structure using UnitFessTestCase and verify the improvements made to error handling, defensive programming, and security enhancements. * Fix test_replaceWithUnderscores_withEdgeCases assertion Corrected the expected value in test_replaceWithUnderscores_withEdgeCases. The input string "//\\[]:;" contains 8 special characters: - // (2 slashes) → __ - \\ (2 backslashes) → __ - []:; (4 characters) → ____ Total: 8 underscores, not 10 as previously expected. This fixes the test failure: expected:<________[__]> but was:<________[]> * Fix flaky test_startProcess_replaceExistingProcess test The test was failing intermittently because it used 'echo' commands which complete almost instantaneously. By the time the assertion checked if the process was running, it had already terminated. Changes: - Replace 'echo' with 'cat' command which waits for input, making it a long-running process suitable for testing - Add polling logic (max 5 seconds) to wait for process to become running before assertions - Add descriptive failure messages to help diagnose timeout issues This makes the test more reliable across different system loads and timing conditions while still testing the intended behavior of replacing an existing process with the same session ID. Note: This fix is independent of LDAP authentication improvements and addresses a pre-existing test stability issue. * Change LDAP error logs to warn level per logging policy Updated logger.error() calls to logger.warn() in LDAP authentication code to follow the Fess logging policy: - error: System-stopping errors where service cannot be provided - warn: Issues where system remains usable (e.g., search still works) Changes: - validate(): LdapConfigurationException now logs at warn level - changePassword(): Both LdapOperationException and general exceptions now log at warn level Rationale: LDAP authentication failures do not prevent the search functionality from working, so these should be warnings rather than errors. * Enhance logging for LDAP errors --------- Co-authored-by: Claude <[email protected]>
1 parent bdc87b8 commit 2f1b2e6

File tree

3 files changed

+493
-63
lines changed

3 files changed

+493
-63
lines changed

src/main/java/org/codelibs/fess/ldap/LdapManager.java

Lines changed: 144 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -195,15 +195,21 @@ protected boolean validate() {
195195
// no credentials
196196
return !fessConfig.isLdapAuthValidation();
197197
}
198-
final Hashtable<String, String> env = createAdminEnv();
199-
try (DirContextHolder holder = getDirContext(() -> env)) {
200-
final DirContext context = holder.get();
201-
if (logger.isDebugEnabled()) {
202-
logger.debug("Logged in as Bind DN. {}", context);
198+
try {
199+
final Hashtable<String, String> env = createAdminEnv();
200+
try (DirContextHolder holder = getDirContext(() -> env)) {
201+
final DirContext context = holder.get();
202+
if (logger.isDebugEnabled()) {
203+
logger.debug("Logged in as Bind DN. {}", context);
204+
}
205+
isBind = true;
203206
}
204-
isBind = true;
207+
} catch (final LdapConfigurationException e) {
208+
logger.warn("LDAP configuration error: {}", e.getMessage(), e);
209+
} catch (final LdapOperationException e) {
210+
logger.warn("LDAP connection failed: {}", e.getMessage(), e);
205211
} catch (final Exception e) {
206-
logger.warn("LDAP configuration is wrong.", e);
212+
logger.warn("Unexpected error during LDAP validation: {}", e.getMessage(), e);
207213
}
208214
}
209215
return isBind;
@@ -217,26 +223,38 @@ protected boolean validate() {
217223
* @return an optional containing the authenticated user if successful, empty otherwise
218224
*/
219225
public OptionalEntity<FessUser> login(final String username, final String password) {
226+
// Add defensive null/blank checks
227+
if (StringUtil.isBlank(username) || StringUtil.isBlank(password)) {
228+
if (logger.isDebugEnabled()) {
229+
logger.debug("Login failed: username or password is blank");
230+
}
231+
return OptionalEntity.empty();
232+
}
233+
220234
if (StringUtil.isBlank(fessConfig.getLdapProviderUrl()) || !validate()) {
221235
return OptionalEntity.empty();
222236
}
223237

224-
final Hashtable<String, String> env = createSearchEnv(username, password);
225-
try (DirContextHolder holder = getDirContext(() -> env)) {
226-
final DirContext context = holder.get();
227-
final LdapUser ldapUser = createLdapUser(username, env);
228-
if (!allowEmptyGroupAndRole(ldapUser)) {
238+
try {
239+
final Hashtable<String, String> env = createSearchEnv(username, password);
240+
try (DirContextHolder holder = getDirContext(() -> env)) {
241+
final DirContext context = holder.get();
242+
final LdapUser ldapUser = createLdapUser(username, env);
243+
if (!allowEmptyGroupAndRole(ldapUser)) {
244+
if (logger.isDebugEnabled()) {
245+
logger.debug("Login failed. No permissions. {}", context);
246+
}
247+
return OptionalEntity.empty();
248+
}
229249
if (logger.isDebugEnabled()) {
230-
logger.debug("Login failed. No permissions. {}", context);
250+
logger.debug("Logged in. {}", context);
231251
}
232-
return OptionalEntity.empty();
233-
}
234-
if (logger.isDebugEnabled()) {
235-
logger.debug("Logged in. {}", context);
252+
return OptionalEntity.of(ldapUser);
236253
}
237-
return OptionalEntity.of(ldapUser);
254+
} catch (final LdapOperationException e) {
255+
logger.debug("LDAP operation failed during login for user: {}", username, e);
238256
} catch (final Exception e) {
239-
logger.debug("Login failed.", e);
257+
logger.debug("Login failed for user: {}", username, e);
240258
}
241259
return OptionalEntity.empty();
242260
}
@@ -248,22 +266,34 @@ public OptionalEntity<FessUser> login(final String username, final String passwo
248266
* @return an optional containing the authenticated user if successful, empty otherwise
249267
*/
250268
public OptionalEntity<FessUser> login(final String username) {
251-
final Hashtable<String, String> env = createSearchEnv();
252-
try (DirContextHolder holder = getDirContext(() -> env)) {
253-
final DirContext context = holder.get();
254-
final LdapUser ldapUser = createLdapUser(username, env);
255-
if (!allowEmptyGroupAndRole(ldapUser)) {
269+
// Add defensive null/blank check
270+
if (StringUtil.isBlank(username)) {
271+
if (logger.isDebugEnabled()) {
272+
logger.debug("Login failed: username is blank");
273+
}
274+
return OptionalEntity.empty();
275+
}
276+
277+
try {
278+
final Hashtable<String, String> env = createSearchEnv();
279+
try (DirContextHolder holder = getDirContext(() -> env)) {
280+
final DirContext context = holder.get();
281+
final LdapUser ldapUser = createLdapUser(username, env);
282+
if (!allowEmptyGroupAndRole(ldapUser)) {
283+
if (logger.isDebugEnabled()) {
284+
logger.debug("Login failed. No permissions. {}", context);
285+
}
286+
return OptionalEntity.empty();
287+
}
256288
if (logger.isDebugEnabled()) {
257-
logger.debug("Login failed. No permissions. {}", context);
289+
logger.debug("Logged in. {}", context);
258290
}
259-
return OptionalEntity.empty();
260-
}
261-
if (logger.isDebugEnabled()) {
262-
logger.debug("Logged in. {}", context);
291+
return OptionalEntity.of(ldapUser);
263292
}
264-
return OptionalEntity.of(ldapUser);
293+
} catch (final LdapOperationException e) {
294+
logger.debug("LDAP operation failed during login for user: {}", username, e);
265295
} catch (final Exception e) {
266-
logger.debug("Login failed.", e);
296+
logger.debug("Login failed for user: {}", username, e);
267297
}
268298
return OptionalEntity.empty();
269299
}
@@ -370,6 +400,14 @@ public String[] getRoles(final LdapUser ldapUser, final String bindDn, final Str
370400
* @return an optional containing the sAMAccountName if found, empty otherwise
371401
*/
372402
protected OptionalEntity<String> getSAMAccountGroupName(final String bindDn, final String groupName) {
403+
// Add defensive null/blank checks
404+
if (StringUtil.isBlank(bindDn) || StringUtil.isBlank(groupName)) {
405+
if (logger.isDebugEnabled()) {
406+
logger.debug("bindDn or groupName is blank: bindDn={}, groupName={}", bindDn, groupName);
407+
}
408+
return OptionalEntity.empty();
409+
}
410+
373411
final Hashtable<String, String> env = createSearchEnv();
374412
try (DirContextHolder holder = getDirContext(() -> env)) {
375413
final DirContext context = holder.get();
@@ -390,8 +428,10 @@ protected OptionalEntity<String> getSAMAccountGroupName(final String bindDn, fin
390428
return OptionalEntity.of(sAMAccountName);
391429
}
392430
}
431+
} catch (final NamingException e) {
432+
logger.warn("LDAP naming exception while getting sAMAccountName for group: {}", groupName, e);
393433
} catch (final Exception e) {
394-
logger.warn("Failed to get sAMAccountName: {}", groupName, e);
434+
logger.warn("Unexpected exception while getting sAMAccountName for group: {}", groupName, e);
395435
}
396436
return OptionalEntity.empty();
397437
}
@@ -463,15 +503,31 @@ protected String updateSearchRoles(final Set<String> roleSet, final String entry
463503
}
464504

465505
/**
466-
* Escapes special characters in an LDAP search filter to prevent injection attacks.
506+
* Escapes special characters in an LDAP search filter to prevent LDAP injection attacks.
507+
*
508+
* <p>This method escapes the following characters as per RFC 4515:
509+
* <ul>
510+
* <li>\ (backslash) → \5c</li>
511+
* <li>* (asterisk) → \2a</li>
512+
* <li>( (left parenthesis) → \28</li>
513+
* <li>) (right parenthesis) → \29</li>
514+
* <li>\0 (null character) → \00</li>
515+
* </ul>
467516
*
468-
* @param filter the LDAP search filter to escape
469-
* @return the escaped filter string
517+
* <p><strong>Security Note:</strong> This method MUST be called on all user-supplied
518+
* input before using it in LDAP search filters to prevent LDAP injection vulnerabilities.
519+
*
520+
* @param filter the LDAP search filter to escape (null is treated as empty string)
521+
* @return the escaped filter string safe for use in LDAP queries (empty string if filter is null)
522+
* @see <a href="https://tools.ietf.org/html/rfc4515">RFC 4515 - LDAP String Representation of Search Filters</a>
470523
*/
471-
protected String escapeLDAPSearchFilter(String filter) {
472-
final StringBuilder sb = new StringBuilder();
524+
protected String escapeLDAPSearchFilter(final String filter) {
525+
if (filter == null) {
526+
return "";
527+
}
528+
final StringBuilder sb = new StringBuilder(filter.length() * 2);
473529
for (int i = 0; i < filter.length(); i++) {
474-
char curChar = filter.charAt(i);
530+
final char curChar = filter.charAt(i);
475531
switch (curChar) {
476532
case '\\':
477533
sb.append("\\5c");
@@ -1458,26 +1514,51 @@ public void delete(final Group group) {
14581514
/**
14591515
* Changes the password for a user in the LDAP directory.
14601516
*
1461-
* @param username the username of the user
1462-
* @param password the new password
1517+
* <p>This method performs the following validations:
1518+
* <ul>
1519+
* <li>Checks if username and password are not blank</li>
1520+
* <li>Verifies LDAP admin is enabled for the user</li>
1521+
* <li>Confirms the user exists in LDAP directory</li>
1522+
* </ul>
1523+
*
1524+
* @param username the username of the user (must not be null or blank)
1525+
* @param password the new password (must not be null or blank)
14631526
* @return true if the password was changed successfully, false otherwise
1527+
* @throws LdapOperationException if the user is not found in LDAP
14641528
*/
14651529
public boolean changePassword(final String username, final String password) {
1466-
if (!fessConfig.isLdapAdminEnabled(username)) {
1530+
// Add defensive null/blank checks
1531+
if (StringUtil.isBlank(username) || StringUtil.isBlank(password)) {
1532+
logger.warn("Cannot change password: username or password is blank");
14671533
return false;
14681534
}
14691535

1470-
final Supplier<Hashtable<String, String>> adminEnv = this::createAdminEnv;
1471-
final String userDN = fessConfig.getLdapAdminUserSecurityPrincipal(username);
1472-
search(fessConfig.getLdapAdminUserBaseDn(), fessConfig.getLdapAdminUserFilter(username), null, adminEnv, result -> {
1473-
if (result.isEmpty()) {
1474-
throw new LdapOperationException("User is not found: " + username);
1536+
if (!fessConfig.isLdapAdminEnabled(username)) {
1537+
if (logger.isDebugEnabled()) {
1538+
logger.debug("LDAP admin not enabled for user: {}", username);
14751539
}
1476-
final List<ModificationItem> modifyList = new ArrayList<>();
1477-
modifyReplaceEntry(modifyList, "userPassword", password);
1478-
modify(userDN, modifyList, adminEnv);
1479-
});
1480-
return true;
1540+
return false;
1541+
}
1542+
1543+
try {
1544+
final Supplier<Hashtable<String, String>> adminEnv = this::createAdminEnv;
1545+
final String userDN = fessConfig.getLdapAdminUserSecurityPrincipal(username);
1546+
search(fessConfig.getLdapAdminUserBaseDn(), fessConfig.getLdapAdminUserFilter(username), null, adminEnv, result -> {
1547+
if (result.isEmpty()) {
1548+
throw new LdapOperationException("User is not found: " + username);
1549+
}
1550+
final List<ModificationItem> modifyList = new ArrayList<>();
1551+
modifyReplaceEntry(modifyList, "userPassword", password);
1552+
modify(userDN, modifyList, adminEnv);
1553+
});
1554+
return true;
1555+
} catch (final LdapOperationException e) {
1556+
logger.warn("Failed to change password for user: {}", username, e);
1557+
throw e;
1558+
} catch (final Exception e) {
1559+
logger.warn("Unexpected error while changing password for user: {}", username, e);
1560+
return false;
1561+
}
14811562
}
14821563

14831564
/**
@@ -1671,13 +1752,19 @@ public void close() {
16711752
if (counter > 1) {
16721753
counter--;
16731754
} else {
1674-
contextLocal.remove();
1675-
if (context != null) {
1676-
try {
1677-
context.close();
1678-
} catch (final NamingException e) {
1679-
// ignored
1755+
try {
1756+
if (context != null) {
1757+
try {
1758+
context.close();
1759+
} catch (final NamingException e) {
1760+
if (logger.isDebugEnabled()) {
1761+
logger.debug("Failed to close LDAP context", e);
1762+
}
1763+
}
16801764
}
1765+
} finally {
1766+
// Ensure ThreadLocal is always cleaned up, even if context.close() fails
1767+
contextLocal.remove();
16811768
}
16821769
}
16831770
}

src/test/java/org/codelibs/fess/helper/ProcessHelperTest.java

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,10 @@ public void test_startProcess_invalidCommand() {
149149

150150
public void test_startProcess_replaceExistingProcess() {
151151
String sessionId = "test_replace";
152-
List<String> cmdList1 = Arrays.asList("echo", "first");
153-
List<String> cmdList2 = Arrays.asList("echo", "second");
152+
// Use 'cat' which waits for input, making it a long-running process
153+
// This is more reliable than 'echo' which completes immediately
154+
List<String> cmdList1 = Arrays.asList("cat");
155+
List<String> cmdList2 = Arrays.asList("cat");
154156
Consumer<ProcessBuilder> pbCall = pb -> {
155157
pb.redirectErrorStream(true);
156158
};
@@ -159,15 +161,32 @@ public void test_startProcess_replaceExistingProcess() {
159161
// Start first process
160162
JobProcess jobProcess1 = processHelper.startProcess(sessionId, cmdList1, pbCall);
161163
assertNotNull(jobProcess1);
162-
assertTrue(processHelper.isProcessRunning(sessionId));
164+
165+
// Poll for process to be running (max 50 times, 100ms interval = 5 seconds max)
166+
boolean isRunning = false;
167+
for (int i = 0; i < 50; i++) {
168+
if (processHelper.isProcessRunning(sessionId)) {
169+
isRunning = true;
170+
break;
171+
}
172+
Thread.sleep(100);
173+
}
174+
assertTrue("First process did not become running within timeout", isRunning);
163175

164176
// Start second process with same session ID (should replace first)
165177
JobProcess jobProcess2 = processHelper.startProcess(sessionId, cmdList2, pbCall);
166178
assertNotNull(jobProcess2);
167-
assertTrue(processHelper.isProcessRunning(sessionId));
168179

169-
// Wait a bit for the processes to complete
170-
Thread.sleep(100);
180+
// Poll for the replacement process to be running
181+
isRunning = false;
182+
for (int i = 0; i < 50; i++) {
183+
if (processHelper.isProcessRunning(sessionId)) {
184+
isRunning = true;
185+
break;
186+
}
187+
Thread.sleep(100);
188+
}
189+
assertTrue("Replacement process did not become running within timeout", isRunning);
171190

172191
// Clean up
173192
processHelper.destroyProcess(sessionId);

0 commit comments

Comments
 (0)