@@ -74,8 +74,8 @@ public PlatformPrincipal login(String username, String password) {
7474 throw new AuthFlowException (HttpStatus .SERVICE_UNAVAILABLE , "error.auth.ldap.disabled" );
7575 }
7676
77- log .debug ("LDAP URL : {}, Base : {}, SearchBase : {}, SearchAttr : {}" ,
78- ldapProperties .getUrl (),
77+ log .debug ("LDAP host : {}, base : {}, searchBase : {}, searchAttr : {}" ,
78+ safeLogHost ( ldapProperties .getUrl ()),
7979 ldapProperties .getBase (),
8080 ldapProperties .getUserSearchBase (),
8181 ldapProperties .getUserSearchAttribute ());
@@ -138,6 +138,12 @@ private void ensureUserCanLogin(UserAccount user) {
138138 * Finds the DN (Distinguished Name) of a user in LDAP.
139139 */
140140 private String findUserDn (String username ) {
141+ // LDAP injection prevention: validate username before search
142+ if (!isValidUsername (username )) {
143+ log .warn ("Invalid username format for LDAP search: {}" , username );
144+ return null ;
145+ }
146+
141147 DirContext ctx = null ;
142148 javax .naming .NamingEnumeration <SearchResult > results = null ;
143149 try {
@@ -181,12 +187,16 @@ private DirContext createLdapContext() throws NamingException {
181187 env .put (Context .INITIAL_CONTEXT_FACTORY , "com.sun.jndi.ldap.LdapCtxFactory" );
182188 env .put (Context .PROVIDER_URL , ldapProperties .getUrl ());
183189 env .put (Context .SECURITY_AUTHENTICATION , "simple" );
184-
190+
191+ // Connection timeout: 5 seconds for connect, 10 seconds for read
192+ env .put ("com.sun.jndi.ldap.connect.timeout" , "5000" );
193+ env .put ("com.sun.jndi.ldap.read.timeout" , "10000" );
194+
185195 if (ldapProperties .getUsername () != null && !ldapProperties .getUsername ().isEmpty ()) {
186196 env .put (Context .SECURITY_PRINCIPAL , ldapProperties .getUsername ());
187197 env .put (Context .SECURITY_CREDENTIALS , ldapProperties .getPassword ());
188198 }
189-
199+
190200 return new InitialDirContext (env );
191201 }
192202
@@ -203,48 +213,79 @@ private void closeContext(DirContext ctx) {
203213 }
204214 }
205215
216+ /**
217+ * Safely extracts host from LDAP URL for logging, avoiding credential exposure.
218+ * Handles formats like: ldap://host:389, ldap://user:pass@host:389, ldaps://host
219+ */
220+ private String safeLogHost (String url ) {
221+ if (url == null || url .isEmpty ()) {
222+ return "" ;
223+ }
224+ try {
225+ // Remove protocol prefix
226+ String withoutProtocol = url .replaceFirst ("^ldaps?://" , "" );
227+ // Extract host:port or just host
228+ int atIndex = withoutProtocol .indexOf ('@' );
229+ if (atIndex > 0 ) {
230+ withoutProtocol = withoutProtocol .substring (atIndex + 1 );
231+ }
232+ int colonIndex = withoutProtocol .indexOf (':' );
233+ return colonIndex > 0 ? withoutProtocol .substring (0 , colonIndex ) : withoutProtocol ;
234+ } catch (Exception e ) {
235+ return "[url-parse-error]" ;
236+ }
237+ }
238+
206239 /**
207240 * Authenticates a user against the LDAP server using their DN and password.
208241 */
209242 private boolean authenticateLdap (String userDn , String password ) {
243+ DirContext ctx = null ;
210244 try {
211245 Hashtable <String , String > env = new Hashtable <>();
212246 env .put (Context .INITIAL_CONTEXT_FACTORY , "com.sun.jndi.ldap.LdapCtxFactory" );
213247 env .put (Context .PROVIDER_URL , ldapProperties .getUrl ());
214248 env .put (Context .SECURITY_AUTHENTICATION , "simple" );
215249 env .put (Context .SECURITY_PRINCIPAL , userDn );
216250 env .put (Context .SECURITY_CREDENTIALS , password );
251+ env .put ("com.sun.jndi.ldap.connect.timeout" , "5000" );
252+ env .put ("com.sun.jndi.ldap.read.timeout" , "10000" );
217253
218- DirContext ctx = new InitialDirContext (env );
219- ctx .close ();
254+ ctx = new InitialDirContext (env );
220255 return true ;
221256 } catch (NamingException e ) {
222257 return false ;
258+ } finally {
259+ closeContext (ctx );
223260 }
224261 }
225262
226263 /**
227264 * Retrieves user attributes from LDAP.
228265 */
229266 private Attributes getUserAttributes (String userDn ) {
267+ DirContext ctx = null ;
230268 try {
231269 Hashtable <String , String > env = new Hashtable <>();
232270 env .put (Context .INITIAL_CONTEXT_FACTORY , "com.sun.jndi.ldap.LdapCtxFactory" );
233271 env .put (Context .PROVIDER_URL , ldapProperties .getUrl ());
234272 env .put (Context .SECURITY_AUTHENTICATION , "simple" );
235-
273+ env .put ("com.sun.jndi.ldap.connect.timeout" , "5000" );
274+ env .put ("com.sun.jndi.ldap.read.timeout" , "10000" );
275+
236276 // Use bind DN if configured, otherwise anonymous bind
237277 if (ldapProperties .getUsername () != null && !ldapProperties .getUsername ().isEmpty ()) {
238278 env .put (Context .SECURITY_PRINCIPAL , ldapProperties .getUsername ());
239279 env .put (Context .SECURITY_CREDENTIALS , ldapProperties .getPassword ());
240280 }
241-
242- DirContext ctx = new InitialDirContext (env );
281+
282+ ctx = new InitialDirContext (env );
243283 Attributes attrs = ctx .getAttributes (new LdapName (userDn ));
244- ctx .close ();
245284 return attrs ;
246285 } catch (Exception e ) {
247286 return null ;
287+ } finally {
288+ closeContext (ctx );
248289 }
249290 }
250291
@@ -272,8 +313,11 @@ private UserAccount findOrCreateLdapUser(String username, Attributes attributes)
272313
273314 // If not found, create a new user
274315 if (user == null ) {
275- // Use a unique identifier based on username if email is missing
276- // This prevents creating duplicate accounts for users without email
316+ // For LDAP users without email, use "ldap:{username}@internal" as a unique identifier.
317+ // This format:
318+ // 1. Prevents duplicate accounts when email attribute is missing
319+ // 2. Clearly identifies the account origin (LDAP vs local)
320+ // 3. Follows email format to satisfy the email NOT NULL constraint
277321 String normalizedEmail = email != null ? email .toLowerCase () : "ldap:" + username + "@internal" ;
278322
279323 user = new UserAccount (
@@ -322,4 +366,16 @@ private PlatformPrincipal buildPrincipal(UserAccount user) {
322366 roles
323367 );
324368 }
369+
370+ /**
371+ * Validates username to prevent LDAP injection attacks.
372+ * Allows only alphanumeric characters and underscores, 3-64 characters.
373+ */
374+ private boolean isValidUsername (String username ) {
375+ if (username == null || username .isEmpty ()) {
376+ return false ;
377+ }
378+ // Allow alphanumeric, underscore, hyphen, dot, and @ for UPN formats
379+ return username .matches ("^[A-Za-z0-9_@.\\ -]{3,64}$" );
380+ }
325381}
0 commit comments