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.