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

ilgrosso pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/syncope.git


The following commit(s) were added to refs/heads/master by this push:
     new 9eb39b482d [SYNCOPE-1973] Avoid exposing authentication error details 
that enable user enumeration (#1414)
9eb39b482d is described below

commit 9eb39b482d60b2379438295f705904859b5242b1
Author: Massimiliano Perrone <[email protected]>
AuthorDate: Mon Jun 15 14:00:12 2026 +0200

    [SYNCOPE-1973] Avoid exposing authentication error details that enable user 
enumeration (#1414)
---
 .../core/spring/security/SecurityProperties.java   | 31 ++++++++
 .../SyncopeBasicAuthenticationEntryPoint.java      | 15 +++-
 .../core/spring/security/WebSecurityContext.java   |  5 +-
 .../SyncopeBasicAuthenticationEntryPointTest.java  | 90 +++++++++++++++++++++-
 core/starter/src/main/resources/core.properties    |  3 +
 .../syncope/fit/core/AuthenticationITCase.java     | 22 ++++++
 .../org/apache/syncope/fit/core/JWTITCase.java     |  3 +-
 .../syncope/fit/core/MultitenancyITCase.java       |  4 +-
 src/main/asciidoc/reference-guide/usage/core.adoc  | 33 ++++++++
 9 files changed, 198 insertions(+), 8 deletions(-)

diff --git 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/SecurityProperties.java
 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/SecurityProperties.java
index fce444315f..296d870ebc 100644
--- 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/SecurityProperties.java
+++ 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/SecurityProperties.java
@@ -68,6 +68,31 @@ public class SecurityProperties {
         }
     }
 
+    public static class AuthenticationErrorProperties {
+
+        public static final String DEFAULT_GENERIC_MESSAGE = "Authentication 
failed";
+
+        private boolean exposeDetails = false;
+
+        private String genericMessage = DEFAULT_GENERIC_MESSAGE;
+
+        public boolean isExposeDetails() {
+            return exposeDetails;
+        }
+
+        public void setExposeDetails(final boolean exposeDetails) {
+            this.exposeDetails = exposeDetails;
+        }
+
+        public String getGenericMessage() {
+            return genericMessage;
+        }
+
+        public void setGenericMessage(final String genericMessage) {
+            this.genericMessage = genericMessage;
+        }
+    }
+
     public static class DigesterProperties {
 
         private int saltIterations = 1;
@@ -149,6 +174,8 @@ public class SecurityProperties {
 
     private final AuthenticationThrottleProperties authenticationThrottle = 
new AuthenticationThrottleProperties();
 
+    private final AuthenticationErrorProperties authenticationError = new 
AuthenticationErrorProperties();
+
     private final DigesterProperties digester = new DigesterProperties();
 
     public String getAdminUser() {
@@ -243,6 +270,10 @@ public class SecurityProperties {
         return authenticationThrottle;
     }
 
+    public AuthenticationErrorProperties getAuthenticationError() {
+        return authenticationError;
+    }
+
     public DigesterProperties getDigester() {
         return digester;
     }
diff --git 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPoint.java
 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPoint.java
index 24efdce799..9693780dbd 100644
--- 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPoint.java
+++ 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPoint.java
@@ -21,6 +21,7 @@ package org.apache.syncope.core.spring.security;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import java.io.IOException;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.syncope.common.rest.api.RESTHeaders;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpStatus;
@@ -32,11 +33,23 @@ import 
org.springframework.security.web.authentication.www.BasicAuthenticationEn
  */
 public class SyncopeBasicAuthenticationEntryPoint extends 
BasicAuthenticationEntryPoint {
 
+    private final SecurityProperties securityProperties;
+
+    public SyncopeBasicAuthenticationEntryPoint(final SecurityProperties 
securityProperties) {
+        this.securityProperties = securityProperties;
+    }
+
     @Override
     public void commence(final HttpServletRequest request, final 
HttpServletResponse response,
             final AuthenticationException authException) throws IOException {
 
-        response.addHeader(RESTHeaders.ERROR_INFO, authException.getMessage());
+        response.addHeader(
+                RESTHeaders.ERROR_INFO,
+                securityProperties.getAuthenticationError().isExposeDetails()
+                ? authException.getMessage()
+                : StringUtils.defaultIfBlank(
+                        
securityProperties.getAuthenticationError().getGenericMessage(),
+                        
SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE));
         if (authException instanceof RateLimitAuthenticationException 
rateLimit) {
             response.addHeader(HttpHeaders.RETRY_AFTER, 
Long.toString(rateLimit.getRetryAfterSeconds()));
             response.sendError(HttpStatus.TOO_MANY_REQUESTS.value(), 
authException.getMessage());
diff --git 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/WebSecurityContext.java
 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/WebSecurityContext.java
index bb948d0961..c99fd6cc83 100644
--- 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/WebSecurityContext.java
+++ 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/WebSecurityContext.java
@@ -84,7 +84,8 @@ public class WebSecurityContext {
             final UsernamePasswordAuthenticationProvider 
usernamePasswordAuthenticationProvider,
             final AccessDeniedHandler accessDeniedHandler,
             final AuthDataAccessor dataAccessor,
-            final DefaultCredentialChecker defaultCredentialChecker) throws 
Exception {
+            final DefaultCredentialChecker defaultCredentialChecker,
+            final SecurityProperties securityProperties) throws Exception {
 
         AuthenticationManager authenticationManager = 
http.getSharedObject(AuthenticationManagerBuilder.class).
                 parentAuthenticationManager(null).
@@ -96,7 +97,7 @@ public class WebSecurityContext {
                 new SyncopeAuthenticationDetailsSource();
 
         SyncopeBasicAuthenticationEntryPoint basicAuthenticationEntryPoint =
-                new SyncopeBasicAuthenticationEntryPoint();
+                new SyncopeBasicAuthenticationEntryPoint(securityProperties);
         basicAuthenticationEntryPoint.setRealmName("Apache Syncope 
authentication");
         http.httpBasic(customizer -> customizer.
                 authenticationEntryPoint(basicAuthenticationEntryPoint).
diff --git 
a/core/spring/src/test/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPointTest.java
 
b/core/spring/src/test/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPointTest.java
index 7f34017eb8..191b6b0028 100644
--- 
a/core/spring/src/test/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPointTest.java
+++ 
b/core/spring/src/test/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPointTest.java
@@ -21,17 +21,32 @@ package org.apache.syncope.core.spring.security;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
 import org.apache.syncope.common.rest.api.RESTHeaders;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpStatus;
 import org.springframework.mock.web.MockHttpServletRequest;
 import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.authentication.DisabledException;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
 
 class SyncopeBasicAuthenticationEntryPointTest {
 
+    private SyncopeBasicAuthenticationEntryPoint entryPoint;
+
+    private SecurityProperties securityProperties;
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        securityProperties = new SecurityProperties();
+        entryPoint = new 
SyncopeBasicAuthenticationEntryPoint(securityProperties);
+        entryPoint.setRealmName("Apache Syncope authentication");
+        entryPoint.afterPropertiesSet();
+    }
+
     @Test
     void rateLimitAuthenticationExceptionReturnsTooManyRequests() throws 
Exception {
-        SyncopeBasicAuthenticationEntryPoint entryPoint = new 
SyncopeBasicAuthenticationEntryPoint();
         MockHttpServletResponse response = new MockHttpServletResponse();
 
         entryPoint.commence(
@@ -41,6 +56,77 @@ class SyncopeBasicAuthenticationEntryPointTest {
 
         assertEquals(HttpStatus.TOO_MANY_REQUESTS.value(), 
response.getStatus());
         assertEquals("30", response.getHeader(HttpHeaders.RETRY_AFTER));
-        assertEquals("Too many authentication failures", 
response.getHeader(RESTHeaders.ERROR_INFO));
+        
assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE,
+                response.getHeader(RESTHeaders.ERROR_INFO));
+    }
+
+    @Test
+    public void badCredentialsExposeGenericErrorInfo() throws Exception {
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        entryPoint.commence(
+                new MockHttpServletRequest(),
+                response,
+                new BadCredentialsException("rossini: invalid password 
provided"));
+
+        assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus());
+        
assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE,
+                response.getHeader(RESTHeaders.ERROR_INFO));
+    }
+
+    @Test
+    public void missingUserExposeGenericErrorInfo() throws Exception {
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        entryPoint.commence(
+                new MockHttpServletRequest(),
+                response,
+                new UsernameNotFoundException("not-a-user"));
+
+        assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus());
+        
assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE,
+                response.getHeader(RESTHeaders.ERROR_INFO));
+    }
+
+    @Test
+    public void disabledUserExposeGenericErrorInfo() throws Exception {
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        entryPoint.commence(
+                new MockHttpServletRequest(),
+                response,
+                new DisabledException("User rossini is suspended"));
+
+        assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus());
+        
assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE,
+                response.getHeader(RESTHeaders.ERROR_INFO));
+    }
+
+    @Test
+    public void genericErrorInfoCanBeConfigured() throws Exception {
+        securityProperties.getAuthenticationError().setGenericMessage("Login 
failed");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        entryPoint.commence(
+                new MockHttpServletRequest(),
+                response,
+                new BadCredentialsException("rossini: invalid password 
provided"));
+
+        assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus());
+        assertEquals("Login failed", 
response.getHeader(RESTHeaders.ERROR_INFO));
+    }
+
+    @Test
+    public void detailsCanBeExposedWhenConfigured() throws Exception {
+        securityProperties.getAuthenticationError().setExposeDetails(true);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        entryPoint.commence(
+                new MockHttpServletRequest(),
+                response,
+                new BadCredentialsException("rossini: invalid password 
provided"));
+
+        assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus());
+        assertEquals("rossini: invalid password provided", 
response.getHeader(RESTHeaders.ERROR_INFO));
     }
 }
diff --git a/core/starter/src/main/resources/core.properties 
b/core/starter/src/main/resources/core.properties
index fb4c232b99..5a522b6fda 100644
--- a/core/starter/src/main/resources/core.properties
+++ b/core/starter/src/main/resources/core.properties
@@ -107,6 +107,9 @@ security.authenticationThrottle.maxAttempts=5
 security.authenticationThrottle.windowSeconds=60
 security.authenticationThrottle.lockSeconds=60
 
+security.authenticationError.exposeDetails=false
+security.authenticationError.genericMessage=Authentication failed
+
 # default for LDAP / RFC2307 SSHA
 security.digester.saltIterations=1
 security.digester.saltSizeBytes=8
diff --git 
a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/AuthenticationITCase.java
 
b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/AuthenticationITCase.java
index 009db1e5b8..529b476263 100644
--- 
a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/AuthenticationITCase.java
+++ 
b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/AuthenticationITCase.java
@@ -71,6 +71,7 @@ import 
org.apache.syncope.common.rest.api.beans.UserRequestQuery;
 import org.apache.syncope.common.rest.api.service.AnyObjectService;
 import org.apache.syncope.common.rest.api.service.SchemaService;
 import org.apache.syncope.common.rest.api.service.UserService;
+import org.apache.syncope.core.spring.security.SecurityProperties;
 import org.apache.syncope.fit.AbstractITCase;
 import org.junit.jupiter.api.Test;
 import org.springframework.jdbc.core.JdbcTemplate;
@@ -324,6 +325,27 @@ public class AuthenticationITCase extends AbstractITCase {
         assertEquals(0, userService4.read(userKey).getFailedLogins());
     }
 
+    @Test
+    public void authenticationFailureErrorInfoIsGeneric() {
+        NotAuthorizedException e = assertThrows(
+                NotAuthorizedException.class,
+                () -> CLIENT_FACTORY.create(getUUIDString(), 
"anypassword").self());
+        
assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE,
+                e.getResponse().getHeaderString(RESTHeaders.ERROR_INFO));
+
+        UserCR userCR = 
UserITCase.getUniqueSample("[email protected]");
+        userCR.getRoles().add("User manager");
+
+        UserTO userTO = createUser(userCR).getEntity();
+        assertNotNull(userTO);
+
+        e = assertThrows(
+                NotAuthorizedException.class,
+                () -> CLIENT_FACTORY.create(userTO.getUsername(), "wrongpwd"));
+        
assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE,
+                e.getResponse().getHeaderString(RESTHeaders.ERROR_INFO));
+    }
+
     @Test
     public void checkUserSuspension() {
         UserCR userCR = 
UserITCase.getUniqueSample("[email protected]");
diff --git 
a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/JWTITCase.java 
b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/JWTITCase.java
index c1f626b9b9..b77027f636 100644
--- 
a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/JWTITCase.java
+++ 
b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/JWTITCase.java
@@ -54,6 +54,7 @@ import org.apache.syncope.common.lib.to.UserTO;
 import org.apache.syncope.common.rest.api.RESTHeaders;
 import org.apache.syncope.common.rest.api.service.AccessTokenService;
 import org.apache.syncope.common.rest.api.service.UserSelfService;
+import org.apache.syncope.core.spring.security.SecurityProperties;
 import org.apache.syncope.core.spring.security.jws.AccessTokenJWSSigner;
 import org.apache.syncope.core.spring.security.jws.AccessTokenJWSVerifier;
 import org.apache.syncope.fit.AbstractITCase;
@@ -142,7 +143,7 @@ public class JWTITCase extends AbstractITCase {
             jwtUserSelfService.read();
             fail("Failure expected on a modified token");
         } catch (NotAuthorizedException e) {
-            assertEquals("Invalid signature found in JWT", e.getMessage());
+            
assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE,
 e.getMessage());
         }
     }
 
diff --git 
a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/MultitenancyITCase.java
 
b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/MultitenancyITCase.java
index be474f0893..40ce6f1996 100644
--- 
a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/MultitenancyITCase.java
+++ 
b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/MultitenancyITCase.java
@@ -21,7 +21,6 @@ package org.apache.syncope.fit.core;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
 import static org.junit.jupiter.api.Assumptions.assumeTrue;
 
@@ -66,6 +65,7 @@ import 
org.apache.syncope.common.rest.api.service.SchemaService;
 import org.apache.syncope.common.rest.api.service.TaskService;
 import org.apache.syncope.common.rest.api.service.UserSelfService;
 import org.apache.syncope.common.rest.api.service.UserService;
+import org.apache.syncope.core.spring.security.SecurityProperties;
 import org.apache.syncope.fit.AbstractITCase;
 import org.identityconnectors.framework.common.objects.ObjectClass;
 import org.junit.jupiter.api.BeforeEach;
@@ -238,7 +238,7 @@ public class MultitenancyITCase extends AbstractITCase {
                     
create(UserITCase.getUniqueSample("[email protected]"));
             fail("This should not happen");
         } catch (NotAuthorizedException e) {
-            assertTrue(e.getMessage().contains("Could not find domain 
NotExisting"));
+            
assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE,
 e.getMessage());
         }
     }
 }
diff --git a/src/main/asciidoc/reference-guide/usage/core.adoc 
b/src/main/asciidoc/reference-guide/usage/core.adoc
index 11f98d3a35..6533ae2292 100644
--- a/src/main/asciidoc/reference-guide/usage/core.adoc
+++ b/src/main/asciidoc/reference-guide/usage/core.adoc
@@ -107,6 +107,39 @@ 
https://docs.spring.io/spring-security/reference/7.0/servlet/authentication/anon
 <<entitlements,entitlements>>, handed over to users via <<roles,roles>>.
 ****
 
+[[authentication-error-details]]
+===== Authentication Error Details
+Since 4.1.2 version, by default, authentication failures return a generic 
error message in the `X-Syncope-Error-Info` response header.
+
+This avoids exposing details that could help distinguish between different 
authentication failure causes, such as an unknown user, invalid credentials, 
disabled account, invalid domain or invalid token.
+
+The behavior can be configured with the following properties:
+[cols="1,1,3",options="header"]
+|===
+|Property
+|Default
+|Description
+|`security.authenticationError.exposeDetails`
+|`false`
+|When `false`, authentication failures expose the configured generic message.  
When `true`, the original authentication exception message is exposed. Setting 
this to `true` restores the previous, more detailed behavior.
+|`security.authenticationError.genericMessage`
+|`Authentication failed`
+|Message returned for authentication failures when 
`security.authenticationError.exposeDetails` is `false`. If left empty, Syncope
+  falls back to `Authentication failed`.
+|===
+Example:
+[source,properties]
+----
+security.authenticationError.exposeDetails=false
+security.authenticationError.genericMessage=Authentication failed
+----
+
+For compatibility with previous behavior, administrators can enable detailed 
messages:
+[source,properties]
+----
+security.authenticationError.exposeDetails=true
+----
+
 ===== Authentication Throttling
 This functionality can throttle repeated failed username / password 
authentication attempts, to reduce the effectiveness of automated guessing 
attacks.
 

Reply via email to