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 7b70556c28 [SYNCOPE-1976] Add configurable REST rate limiting (#1420)
7b70556c28 is described below

commit 7b70556c283b6375f91c9a77bff75184bed059b9
Author: Massimiliano Perrone <[email protected]>
AuthorDate: Mon Jun 15 16:20:06 2026 +0200

    [SYNCOPE-1976] Add configurable REST rate limiting (#1420)
---
 core/idrepo/rest-cxf/pom.xml                       |  10 ++
 .../core/rest/cxf/IdRepoRESTCXFContext.java        |  38 +++++
 .../syncope/core/rest/cxf/RESTProperties.java      |  75 ++++++++++
 .../syncope/core/rest/cxf/RateLimitFilter.java     | 158 +++++++++++++++++++++
 .../syncope/core/rest/cxf/RateLimitFilterTest.java | 120 ++++++++++++++++
 .../security/AuthenticationAttemptThrottler.java   |   4 +-
 .../SyncopeBasicAuthenticationEntryPoint.java      |   8 +-
 .../SyncopeBasicAuthenticationEntryPointTest.java  |  24 ++--
 .../syncope/fit/core/AuthenticationITCase.java     |   8 +-
 src/main/asciidoc/reference-guide/usage/core.adoc  |  62 ++++++++
 10 files changed, 487 insertions(+), 20 deletions(-)

diff --git a/core/idrepo/rest-cxf/pom.xml b/core/idrepo/rest-cxf/pom.xml
index 1e2d86e304..f2c0bf2193 100644
--- a/core/idrepo/rest-cxf/pom.xml
+++ b/core/idrepo/rest-cxf/pom.xml
@@ -49,6 +49,11 @@ under the License.
       <artifactId>jakarta.persistence-api</artifactId>
     </dependency>
 
+    <dependency>
+      <groupId>javax.cache</groupId>
+      <artifactId>cache-api</artifactId>
+    </dependency>
+
     <dependency>
       <groupId>tools.jackson.jakarta.rs</groupId>      
       <artifactId>jackson-jakarta-rs-json-provider</artifactId>
@@ -128,6 +133,11 @@ under the License.
       <artifactId>mockito-junit-jupiter</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>com.github.ben-manes.caffeine</groupId>
+      <artifactId>jcache</artifactId>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
   <build>
diff --git 
a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/IdRepoRESTCXFContext.java
 
b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/IdRepoRESTCXFContext.java
index 0865d2aa2c..7196be2577 100644
--- 
a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/IdRepoRESTCXFContext.java
+++ 
b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/IdRepoRESTCXFContext.java
@@ -20,10 +20,17 @@ package org.apache.syncope.core.rest.cxf;
 
 import io.swagger.v3.oas.models.security.SecurityScheme;
 import jakarta.validation.Validator;
+import java.time.Duration;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import javax.cache.Cache;
+import javax.cache.CacheManager;
+import javax.cache.configuration.MutableConfiguration;
+import javax.cache.expiry.TouchedExpiryPolicy;
 import org.apache.cxf.Bus;
 import org.apache.cxf.endpoint.Server;
 import org.apache.cxf.feature.Feature;
@@ -227,6 +234,35 @@ public class IdRepoRESTCXFContext {
         return new AddETagFilter();
     }
 
+    @ConditionalOnMissingBean
+    @Bean
+    public RateLimitFilter rateLimitFilter(
+            final RESTProperties props,
+            @Qualifier(RateLimitFilter.CACHE)
+            final Cache<String, RateLimitFilter.ClientWindow> 
cxfRateLimitCache) {
+
+        return new RateLimitFilter(props, cxfRateLimitCache);
+    }
+
+    @ConditionalOnMissingBean(name = RateLimitFilter.CACHE)
+    @Bean(name = RateLimitFilter.CACHE)
+    public Cache<String, RateLimitFilter.ClientWindow> rateLimitCache(
+            final CacheManager cacheManager,
+            final RESTProperties restProperties) {
+
+        long windowMillis = 
Optional.ofNullable(restProperties.getRateLimit().getWindow()).
+                orElse(Duration.ofMinutes(1)).toMillis();
+        long lockMillis = 
Optional.ofNullable(restProperties.getRateLimit().getLock()).
+                orElse(Duration.ofMinutes(1)).toMillis();
+        long expiry = Math.max(1L, Math.max(windowMillis, lockMillis));
+
+        return cacheManager.createCache(RateLimitFilter.CACHE,
+                new MutableConfiguration<String, 
RateLimitFilter.ClientWindow>().
+                        setTypes(String.class, 
RateLimitFilter.ClientWindow.class).
+                        setExpiryPolicyFactory(TouchedExpiryPolicy.factoryOf(
+                                new 
javax.cache.expiry.Duration(TimeUnit.MILLISECONDS, expiry))));
+    }
+
     @ConditionalOnMissingBean(name = { "openApiCustomizer", 
"syncopeOpenApiCustomizer" })
     @Bean
     public OpenApiCustomizer openApiCustomizer(final DomainHolder<?> 
domainHolder, final Environment env) {
@@ -278,6 +314,7 @@ public class IdRepoRESTCXFContext {
             final List<JAXRSService> services,
             final AddETagFilter addETagFilter,
             final AddDomainFilter addDomainFilter,
+            final RateLimitFilter rateLimitFilter,
             final ContextProvider<SearchContext> searchContextProvider,
             final JacksonJsonProvider jsonProvider,
             final DateParamConverterProvider dateParamConverterProvider,
@@ -308,6 +345,7 @@ public class IdRepoRESTCXFContext {
                 jsonProvider,
                 restServiceExceptionMapper,
                 searchContextProvider,
+                rateLimitFilter,
                 addDomainFilter,
                 addETagFilter));
 
diff --git 
a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RESTProperties.java
 
b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RESTProperties.java
index 5fc8473f15..a7ddd4d04f 100644
--- 
a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RESTProperties.java
+++ 
b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RESTProperties.java
@@ -18,6 +18,9 @@
  */
 package org.apache.syncope.core.rest.cxf;
 
+import java.time.Duration;
+import java.util.HashSet;
+import java.util.Set;
 import org.apache.syncope.core.provisioning.java.ExecutorProperties;
 import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.boot.context.properties.NestedConfigurationProperty;
@@ -25,10 +28,82 @@ import 
org.springframework.boot.context.properties.NestedConfigurationProperty;
 @ConfigurationProperties("rest")
 public class RESTProperties {
 
+    public static class RateLimitProperties {
+
+        private boolean enabled;
+
+        private int maxRequests = 300;
+
+        private Duration window = Duration.ofMinutes(1);
+
+        private Duration lock = Duration.ofMinutes(1);
+
+        private String forwardedForHeader = "X-Forwarded-For";
+
+        private final Set<String> excludedAddresses = new HashSet<>();
+
+        private final Set<String> trustedProxies = new HashSet<>();
+
+        public boolean isEnabled() {
+            return enabled;
+        }
+
+        public void setEnabled(final boolean enabled) {
+            this.enabled = enabled;
+        }
+
+        public int getMaxRequests() {
+            return maxRequests;
+        }
+
+        public void setMaxRequests(final int maxRequests) {
+            this.maxRequests = maxRequests;
+        }
+
+        public Duration getWindow() {
+            return window;
+        }
+
+        public void setWindow(final Duration window) {
+            this.window = window;
+        }
+
+        public Duration getLock() {
+            return lock;
+        }
+
+        public void setLock(final Duration lock) {
+            this.lock = lock;
+        }
+
+        public String getForwardedForHeader() {
+            return forwardedForHeader;
+        }
+
+        public void setForwardedForHeader(final String forwardedForHeader) {
+            this.forwardedForHeader = forwardedForHeader;
+        }
+
+        public Set<String> getExcludedAddresses() {
+            return excludedAddresses;
+        }
+
+        public Set<String> getTrustedProxies() {
+            return trustedProxies;
+        }
+    }
+
     @NestedConfigurationProperty
     private final ExecutorProperties batchExecutor = new ExecutorProperties();
 
+    @NestedConfigurationProperty
+    private final RateLimitProperties rateLimitProperties = new 
RateLimitProperties();
+
     public ExecutorProperties getBatchExecutor() {
         return batchExecutor;
     }
+
+    public RateLimitProperties getRateLimit() {
+        return rateLimitProperties;
+    }
 }
diff --git 
a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RateLimitFilter.java
 
b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RateLimitFilter.java
new file mode 100644
index 0000000000..fe334fa9aa
--- /dev/null
+++ 
b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RateLimitFilter.java
@@ -0,0 +1,158 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.syncope.core.rest.cxf;
+
+import jakarta.annotation.Priority;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.ws.rs.Priorities;
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.container.ContainerRequestFilter;
+import jakarta.ws.rs.container.PreMatching;
+import jakarta.ws.rs.core.Context;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.ext.Provider;
+import java.io.Serializable;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Optional;
+import javax.cache.Cache;
+import org.apache.commons.lang3.StringUtils;
+
+@Provider
+@PreMatching
+@Priority(Priorities.AUTHENTICATION - 100)
+public class RateLimitFilter implements ContainerRequestFilter {
+
+    public static final String CACHE = "RateLimitFilterCache";
+
+    protected record RateLimitDecision(boolean allowed, long 
retryAfterSeconds) {
+
+    }
+
+    public record ClientWindow(long windowStartMillis, int count, long 
lockedUntilMillis) implements Serializable {
+
+        private static final long serialVersionUID = -473897805205955157L;
+
+    }
+
+    protected final RESTProperties.RateLimitProperties props;
+
+    protected final Cache<String, ClientWindow> clients;
+
+    @Context
+    protected HttpServletRequest request;
+
+    public RateLimitFilter(final RESTProperties props, final Cache<String, 
ClientWindow> clients) {
+        this.props = props.getRateLimit();
+        this.clients = clients;
+    }
+
+    @Override
+    public void filter(final ContainerRequestContext requestContext) {
+        if (!props.isEnabled() || props.getMaxRequests() <= 0) {
+            return;
+        }
+
+        if (isExcluded()) {
+            return;
+        }
+
+        String key = clientAddress();
+        RateLimitDecision decision = allow(key, System.currentTimeMillis());
+        if (!decision.allowed()) {
+            requestContext.abortWith(Response.status(429).
+                    header(HttpHeaders.RETRY_AFTER, 
decision.retryAfterSeconds()).
+                    build());
+        }
+    }
+
+    protected RateLimitDecision allow(final String key, final long now) {
+        return clients.invoke(key, (entry, args) -> {
+            ClientWindow client = entry.exists()
+                    ? entry.getValue()
+                    : new ClientWindow(now, 0, 0);
+
+            if (now < client.lockedUntilMillis()) {
+                return new RateLimitDecision(false, 
retryAfterSeconds(client.lockedUntilMillis() - now));
+            }
+
+            long windowStartMillis = client.windowStartMillis();
+            int count = client.count();
+            if (now - windowStartMillis >= toMillis(props.getWindow())) {
+                windowStartMillis = now;
+                count = 0;
+            }
+
+            count++;
+            if (count > props.getMaxRequests()) {
+                entry.setValue(new ClientWindow(windowStartMillis, count, now 
+ toMillis(props.getLock())));
+                return new RateLimitDecision(false, 
retryAfterSeconds(toMillis(props.getLock())));
+            }
+
+            entry.setValue(new ClientWindow(windowStartMillis, count, 
client.lockedUntilMillis()));
+            return new RateLimitDecision(true, 0);
+        });
+    }
+
+    protected String clientAddress() {
+        String remoteAddress = remoteAddress();
+
+        if (props.getTrustedProxies().contains(remoteAddress)) {
+            String forwardedFor = Optional.ofNullable(request).
+                    map(req -> req.getHeader(props.getForwardedForHeader())).
+                    flatMap(RateLimitFilter::firstForwardedFor).
+                    orElse(null);
+            if (StringUtils.isNotBlank(forwardedFor)) {
+                return forwardedFor;
+            }
+        }
+
+        return remoteAddress;
+    }
+
+    protected boolean isExcluded() {
+        return props.getExcludedAddresses().contains(remoteAddress());
+    }
+
+    protected String remoteAddress() {
+        return requestRemoteAddress().
+                filter(StringUtils::isNotBlank).
+                orElse("unknown");
+    }
+
+    protected Optional<String> requestRemoteAddress() {
+        return 
Optional.ofNullable(request).map(HttpServletRequest::getRemoteAddr);
+    }
+
+    protected static Optional<String> firstForwardedFor(final String header) {
+        return Arrays.stream(StringUtils.split(header, ',')).
+                map(String::trim).
+                filter(StringUtils::isNotBlank).
+                findFirst();
+    }
+
+    protected static long toMillis(final Duration duration) {
+        return Math.max(1L, 
Optional.ofNullable(duration).orElse(Duration.ofMinutes(1)).toMillis());
+    }
+
+    protected static long retryAfterSeconds(final long millis) {
+        return Math.max(1L, (long) Math.ceil(millis / 1000.0d));
+    }
+}
diff --git 
a/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/RateLimitFilterTest.java
 
b/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/RateLimitFilterTest.java
new file mode 100644
index 0000000000..833aaaab61
--- /dev/null
+++ 
b/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/RateLimitFilterTest.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.syncope.core.rest.cxf;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.Response;
+import java.time.Duration;
+import javax.cache.Cache;
+import javax.cache.Caching;
+import javax.cache.configuration.MutableConfiguration;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.util.ReflectionTestUtils;
+
+class RateLimitFilterTest {
+
+    private static final Cache<String, RateLimitFilter.ClientWindow> CACHE =
+            Caching.getCachingProvider().getCacheManager().createCache(
+                    RateLimitFilter.CACHE, new MutableConfiguration<>());
+
+    private static RateLimitFilter filter(final MockHttpServletRequest 
request) {
+        RESTProperties props = new RESTProperties();
+        props.getRateLimit().setEnabled(true);
+        props.getRateLimit().setMaxRequests(2);
+        props.getRateLimit().setWindow(Duration.ofMinutes(1));
+        props.getRateLimit().setLock(Duration.ofSeconds(30));
+
+        RateLimitFilter filter = new RateLimitFilter(props, CACHE);
+        ReflectionTestUtils.setField(filter, "request", request);
+        return filter;
+    }
+
+    @Test
+    void blocksWhenClientExceedsLimit() {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setRemoteAddr("10.0.0.10");
+
+        RateLimitFilter filter = filter(request);
+        ContainerRequestContext requestContext = 
mock(ContainerRequestContext.class);
+
+        filter.filter(requestContext);
+        filter.filter(requestContext);
+        verify(requestContext, never()).abortWith(any());
+
+        filter.filter(requestContext);
+
+        ArgumentCaptor<Response> response = 
ArgumentCaptor.forClass(Response.class);
+        verify(requestContext).abortWith(response.capture());
+        assertEquals(429, response.getValue().getStatus());
+        assertEquals("30", 
response.getValue().getHeaderString(HttpHeaders.RETRY_AFTER));
+    }
+
+    @Test
+    void usesForwardedForOnlyFromTrustedProxy() {
+        RESTProperties props = new RESTProperties();
+        props.getRateLimit().setEnabled(true);
+        props.getRateLimit().setMaxRequests(1);
+        props.getRateLimit().getTrustedProxies().add("127.0.0.1");
+
+        MockHttpServletRequest trustedProxyRequest = new 
MockHttpServletRequest();
+        trustedProxyRequest.setRemoteAddr("127.0.0.1");
+        trustedProxyRequest.addHeader("X-Forwarded-For", "203.0.113.10, 
198.51.100.20");
+
+        RateLimitFilter trustedProxyFilter = new RateLimitFilter(props, CACHE);
+        ReflectionTestUtils.setField(trustedProxyFilter, "request", 
trustedProxyRequest);
+        assertEquals("203.0.113.10", trustedProxyFilter.clientAddress());
+
+        MockHttpServletRequest untrustedRequest = new MockHttpServletRequest();
+        untrustedRequest.setRemoteAddr("198.51.100.30");
+        untrustedRequest.addHeader("X-Forwarded-For", "203.0.113.10");
+
+        RateLimitFilter untrustedFilter = new RateLimitFilter(props, CACHE);
+        ReflectionTestUtils.setField(untrustedFilter, "request", 
untrustedRequest);
+        assertEquals("198.51.100.30", untrustedFilter.clientAddress());
+    }
+
+    @Test
+    void skipsExcludedRemoteAddress() {
+        RESTProperties props = new RESTProperties();
+        props.getRateLimit().setEnabled(true);
+        props.getRateLimit().setMaxRequests(1);
+        props.getRateLimit().getExcludedAddresses().add("10.0.0.20");
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setRemoteAddr("10.0.0.20");
+
+        RateLimitFilter filter = new RateLimitFilter(props, CACHE);
+        ReflectionTestUtils.setField(filter, "request", request);
+        ContainerRequestContext requestContext = 
mock(ContainerRequestContext.class);
+
+        filter.filter(requestContext);
+        filter.filter(requestContext);
+
+        verify(requestContext, never()).abortWith(any());
+    }
+}
diff --git 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/AuthenticationAttemptThrottler.java
 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/AuthenticationAttemptThrottler.java
index e552a70c3f..afafd89d29 100644
--- 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/AuthenticationAttemptThrottler.java
+++ 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/AuthenticationAttemptThrottler.java
@@ -28,6 +28,8 @@ import org.apache.commons.lang3.StringUtils;
 
 public class AuthenticationAttemptThrottler {
 
+    public static final String CACHE = "AuthenticationAttemptCache";
+
     public record Attempts(Deque<Long> failures, long blockedUntil) implements 
Serializable {
 
         private static final long serialVersionUID = 8023582605543650484L;
@@ -41,8 +43,6 @@ public class AuthenticationAttemptThrottler {
         }
     }
 
-    public static final String CACHE = "AuthenticationAttemptCache";
-
     protected static String key(final String domain, final String username) {
         return StringUtils.defaultString(domain) + ':' + 
StringUtils.defaultString(username);
     }
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 9693780dbd..33cb97081f 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
@@ -33,10 +33,10 @@ import 
org.springframework.security.web.authentication.www.BasicAuthenticationEn
  */
 public class SyncopeBasicAuthenticationEntryPoint extends 
BasicAuthenticationEntryPoint {
 
-    private final SecurityProperties securityProperties;
+    protected final SecurityProperties.AuthenticationErrorProperties props;
 
     public SyncopeBasicAuthenticationEntryPoint(final SecurityProperties 
securityProperties) {
-        this.securityProperties = securityProperties;
+        this.props = securityProperties.getAuthenticationError();
     }
 
     @Override
@@ -45,10 +45,10 @@ public class SyncopeBasicAuthenticationEntryPoint extends 
BasicAuthenticationEnt
 
         response.addHeader(
                 RESTHeaders.ERROR_INFO,
-                securityProperties.getAuthenticationError().isExposeDetails()
+                props.isExposeDetails()
                 ? authException.getMessage()
                 : StringUtils.defaultIfBlank(
-                        
securityProperties.getAuthenticationError().getGenericMessage(),
+                        props.getGenericMessage(),
                         
SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE));
         if (authException instanceof RateLimitAuthenticationException 
rateLimit) {
             response.addHeader(HttpHeaders.RETRY_AFTER, 
Long.toString(rateLimit.getRetryAfterSeconds()));
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 191b6b0028..04d9714e37 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
@@ -38,7 +38,7 @@ class SyncopeBasicAuthenticationEntryPointTest {
     private SecurityProperties securityProperties;
 
     @BeforeEach
-    public void setUp() throws Exception {
+    void setUp() throws Exception {
         securityProperties = new SecurityProperties();
         entryPoint = new 
SyncopeBasicAuthenticationEntryPoint(securityProperties);
         entryPoint.setRealmName("Apache Syncope authentication");
@@ -56,12 +56,13 @@ class SyncopeBasicAuthenticationEntryPointTest {
 
         assertEquals(HttpStatus.TOO_MANY_REQUESTS.value(), 
response.getStatus());
         assertEquals("30", response.getHeader(HttpHeaders.RETRY_AFTER));
-        
assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE,
+        assertEquals(
+                
SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE,
                 response.getHeader(RESTHeaders.ERROR_INFO));
     }
 
     @Test
-    public void badCredentialsExposeGenericErrorInfo() throws Exception {
+    void badCredentialsExposeGenericErrorInfo() throws Exception {
         MockHttpServletResponse response = new MockHttpServletResponse();
 
         entryPoint.commence(
@@ -70,12 +71,13 @@ class SyncopeBasicAuthenticationEntryPointTest {
                 new BadCredentialsException("rossini: invalid password 
provided"));
 
         assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus());
-        
assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE,
+        assertEquals(
+                
SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE,
                 response.getHeader(RESTHeaders.ERROR_INFO));
     }
 
     @Test
-    public void missingUserExposeGenericErrorInfo() throws Exception {
+    void missingUserExposeGenericErrorInfo() throws Exception {
         MockHttpServletResponse response = new MockHttpServletResponse();
 
         entryPoint.commence(
@@ -84,12 +86,13 @@ class SyncopeBasicAuthenticationEntryPointTest {
                 new UsernameNotFoundException("not-a-user"));
 
         assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus());
-        
assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE,
+        assertEquals(
+                
SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE,
                 response.getHeader(RESTHeaders.ERROR_INFO));
     }
 
     @Test
-    public void disabledUserExposeGenericErrorInfo() throws Exception {
+    void disabledUserExposeGenericErrorInfo() throws Exception {
         MockHttpServletResponse response = new MockHttpServletResponse();
 
         entryPoint.commence(
@@ -98,12 +101,13 @@ class SyncopeBasicAuthenticationEntryPointTest {
                 new DisabledException("User rossini is suspended"));
 
         assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus());
-        
assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE,
+        assertEquals(
+                
SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE,
                 response.getHeader(RESTHeaders.ERROR_INFO));
     }
 
     @Test
-    public void genericErrorInfoCanBeConfigured() throws Exception {
+    void genericErrorInfoCanBeConfigured() throws Exception {
         securityProperties.getAuthenticationError().setGenericMessage("Login 
failed");
         MockHttpServletResponse response = new MockHttpServletResponse();
 
@@ -117,7 +121,7 @@ class SyncopeBasicAuthenticationEntryPointTest {
     }
 
     @Test
-    public void detailsCanBeExposedWhenConfigured() throws Exception {
+    void detailsCanBeExposedWhenConfigured() throws Exception {
         securityProperties.getAuthenticationError().setExposeDetails(true);
         MockHttpServletResponse response = new MockHttpServletResponse();
 
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 529b476263..a403d8bc22 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
@@ -330,19 +330,19 @@ public class AuthenticationITCase extends AbstractITCase {
         NotAuthorizedException e = assertThrows(
                 NotAuthorizedException.class,
                 () -> CLIENT_FACTORY.create(getUUIDString(), 
"anypassword").self());
-        
assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE,
+        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,
+        assertEquals(
+                
SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE,
                 e.getResponse().getHeaderString(RESTHeaders.ERROR_INFO));
     }
 
diff --git a/src/main/asciidoc/reference-guide/usage/core.adoc 
b/src/main/asciidoc/reference-guide/usage/core.adoc
index 6533ae2292..da64d10609 100644
--- a/src/main/asciidoc/reference-guide/usage/core.adoc
+++ b/src/main/asciidoc/reference-guide/usage/core.adoc
@@ -48,6 +48,68 @@ protocol://host:port/syncope/
 ....
 ====
 
+==== REST Rate Limiting
+Rate limiting can be applied to REST requests before authentication and 
request processing.
+
+This feature is disabled by default and can be enabled with the following 
properties:
+
+[cols="1,1,3",options="header"]
+|===
+|Property
+|Default
+|Description
+|`rest.rateLimit.enabled`
+|`false`
+|Enables or disables REST request rate limiting.
+|`rest.rateLimit.maxRequests`
+|`300`
+|Maximum number of requests allowed from the same client address within the 
configured time window.
+|`rest.rateLimit.window`
+|`1m`
+|Time window used to count requests from the same client address.
+|`rest.rateLimit.lock`
+|`1m`
+|Amount of time for which the client address is blocked after exceeding the 
configured request limit.
+|`rest.rateLimit.forwardedForHeader`
+|`X-Forwarded-For`
+|HTTP header used to resolve the original client address when the request 
comes from a trusted proxy.
+|`rest.rateLimit.trustedProxies`
+|Empty
+|Set of proxy addresses whose `X-Forwarded-For` header value is
+trusted. The header is ignored for requests not coming from one of these 
addresses.
+|`rest.rateLimit.excludedAddresses`
+|Empty
+|Set of client addresses excluded from rate limiting. This is where addresses 
for trusted internal clients,
+such as Admin Console or Enduser instances calling the Core REST API, should 
be configured when their
+traffic must not be rate limited.
+|===
+
+Addresses are matched exactly as reported by the servlet request remote 
address. CIDR ranges and wildcard
+patterns are not supported.
+
+When Core is deployed behind a reverse proxy, configure the proxy address under
+`rest.rateLimit.trustedProxies` to allow for the original client from the 
configured `rest.rateLimit.forwardedForHeader`
+to be identified. +
+Add addresses to `rest.rateLimit.excludedAddresses` only for callers that 
should bypass rate limiting
+entirely, for example trusted Console instances or internal monitoring clients.
+
+Example:
+[source,properties]
+----
+rest.rateLimit.enabled=true
+rest.rateLimit.maxRequests=300
+rest.rateLimit.window=1m
+rest.rateLimit.lock=1m
+rest.rateLimit.trustedProxies=127.0.0.1
+rest.rateLimit.excludedAddresses=127.0.0.1,10.0.0.10
+----
+When the limit is exceeded, the following HTTP response is returned:
+[source]
+----
+HTTP/1.1 429 Too Many Requests
+Retry-After: <seconds>
+----
+
 ==== REST Authentication and Authorization
 
 The <<core>> authentication and authorization is based on 
https://spring.io/projects/spring-security[Spring Security^].

Reply via email to