From 0a587fb6e5ef7330b233a94abb0ff495031e663c Mon Sep 17 00:00:00 2001 From: Massimiliano Perrone Date: Tue, 9 Jun 2026 22:33:57 +0200 Subject: [PATCH 1/7] [SYNCOPE-1976] Add configurable REST rate limiting --- core/idrepo/rest-cxf/pom.xml | 10 + .../core/rest/cxf/CXFRateLimitFilter.java | 177 ++++++++++++++++++ .../core/rest/cxf/IdRepoRESTCXFContext.java | 25 +++ .../syncope/core/rest/cxf/RESTProperties.java | 75 ++++++++ .../core/rest/cxf/CXFRateLimitFilterTest.java | 120 ++++++++++++ 5 files changed, 407 insertions(+) create mode 100644 core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/CXFRateLimitFilter.java create mode 100644 core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/CXFRateLimitFilterTest.java diff --git a/core/idrepo/rest-cxf/pom.xml b/core/idrepo/rest-cxf/pom.xml index 1e2d86e3043..f2c0bf2193a 100644 --- a/core/idrepo/rest-cxf/pom.xml +++ b/core/idrepo/rest-cxf/pom.xml @@ -49,6 +49,11 @@ under the License. jakarta.persistence-api + + javax.cache + cache-api + + tools.jackson.jakarta.rs jackson-jakarta-rs-json-provider @@ -128,6 +133,11 @@ under the License. mockito-junit-jupiter test + + com.github.ben-manes.caffeine + jcache + test + diff --git a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/CXFRateLimitFilter.java b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/CXFRateLimitFilter.java new file mode 100644 index 00000000000..6efb2e7dcbc --- /dev/null +++ b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/CXFRateLimitFilter.java @@ -0,0 +1,177 @@ +/* + * 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 java.util.concurrent.TimeUnit; +import javax.cache.Cache; +import javax.cache.configuration.MutableConfiguration; +import javax.cache.expiry.TouchedExpiryPolicy; +import org.apache.commons.lang3.StringUtils; + +@Provider +@PreMatching +@Priority(Priorities.AUTHENTICATION - 100) +public class CXFRateLimitFilter implements ContainerRequestFilter { + + public static final String CACHE_NAME = + "org.apache.syncope.core.rest.cxf.CXFRateLimitFilter"; + + protected record RateLimitDecision(boolean allowed, long retryAfterSeconds) { + } + + public record ClientWindow(long windowStartMillis, int count, long lockedUntilMillis) implements Serializable { + + private static final long serialVersionUID = -473897805205955157L; + } + + public static MutableConfiguration cacheConfiguration(final RESTProperties.RateLimit props) { + return new MutableConfiguration(). + setTypes(String.class, ClientWindow.class). + setExpiryPolicyFactory(TouchedExpiryPolicy.factoryOf( + new javax.cache.expiry.Duration(TimeUnit.MILLISECONDS, cacheExpiryMillis(props)))); + } + + protected final RESTProperties.RateLimit props; + + protected final Cache clients; + + @Context + protected HttpServletRequest request; + + public CXFRateLimitFilter(final RESTProperties props, final Cache 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(CXFRateLimitFilter::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 requestRemoteAddress() { + try { + return Optional.ofNullable(request).map(HttpServletRequest::getRemoteAddr); + } catch (NullPointerException e) { + return Optional.empty(); + } + } + + protected static Optional firstForwardedFor(final String header) { + return Arrays.stream(StringUtils.split(header, ',')). + map(String::trim). + filter(StringUtils::isNotBlank). + findFirst(); + } + + protected long toMillis(final Duration duration) { + return Math.max(1L, Optional.ofNullable(duration).orElse(Duration.ofMinutes(1)).toMillis()); + } + + protected static long cacheExpiryMillis(final RESTProperties.RateLimit props) { + long windowMillis = Optional.ofNullable(props.getWindow()).orElse(Duration.ofMinutes(1)).toMillis(); + long lockMillis = Optional.ofNullable(props.getLock()).orElse(Duration.ofMinutes(1)).toMillis(); + return Math.max(1L, Math.max(windowMillis, lockMillis)); + } + + 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/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 0865d2aa2c3..6e2703e9570 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 @@ -24,6 +24,8 @@ import java.util.List; import java.util.Map; import java.util.Set; +import javax.cache.Cache; +import javax.cache.CacheManager; import org.apache.cxf.Bus; import org.apache.cxf.endpoint.Server; import org.apache.cxf.feature.Feature; @@ -227,6 +229,27 @@ public AddETagFilter addETagFilter() { return new AddETagFilter(); } + @ConditionalOnMissingBean + @Bean + public CXFRateLimitFilter cxfRateLimitFilter( + final RESTProperties props, + @Qualifier(CXFRateLimitFilter.CACHE_NAME) + final Cache cxfRateLimitCache) { + + return new CXFRateLimitFilter(props, cxfRateLimitCache); + } + + @ConditionalOnMissingBean(name = CXFRateLimitFilter.CACHE_NAME) + @Bean(name = CXFRateLimitFilter.CACHE_NAME) + public Cache cxfRateLimitCache( + final CacheManager cacheManager, + final RESTProperties props) { + + return cacheManager.createCache( + CXFRateLimitFilter.CACHE_NAME, + CXFRateLimitFilter.cacheConfiguration(props.getRateLimit())); + } + @ConditionalOnMissingBean(name = { "openApiCustomizer", "syncopeOpenApiCustomizer" }) @Bean public OpenApiCustomizer openApiCustomizer(final DomainHolder domainHolder, final Environment env) { @@ -278,6 +301,7 @@ public Server restContainer( final List services, final AddETagFilter addETagFilter, final AddDomainFilter addDomainFilter, + final CXFRateLimitFilter cxfRateLimitFilter, final ContextProvider searchContextProvider, final JacksonJsonProvider jsonProvider, final DateParamConverterProvider dateParamConverterProvider, @@ -308,6 +332,7 @@ public Server restContainer( jsonProvider, restServiceExceptionMapper, searchContextProvider, + cxfRateLimitFilter, 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 5fc8473f15b..0dcc4feb680 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; @@ -28,7 +31,79 @@ public class RESTProperties { @NestedConfigurationProperty private final ExecutorProperties batchExecutor = new ExecutorProperties(); + @NestedConfigurationProperty + private final RateLimit rateLimit = new RateLimit(); + public ExecutorProperties getBatchExecutor() { return batchExecutor; } + + public RateLimit getRateLimit() { + return rateLimit; + } + + public static class RateLimit { + + 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 excludedAddresses = new HashSet<>(); + + private final Set 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 getExcludedAddresses() { + return excludedAddresses; + } + + public Set getTrustedProxies() { + return trustedProxies; + } + } } diff --git a/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/CXFRateLimitFilterTest.java b/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/CXFRateLimitFilterTest.java new file mode 100644 index 00000000000..2a5ac8a8cb5 --- /dev/null +++ b/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/CXFRateLimitFilterTest.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.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 java.util.UUID; +import javax.cache.Cache; +import javax.cache.Caching; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.util.ReflectionTestUtils; + +class CXFRateLimitFilterTest { + + private static Cache cache(final RESTProperties props) { + return Caching.getCachingProvider().getCacheManager().createCache( + CXFRateLimitFilterTest.class.getName() + '-' + UUID.randomUUID(), + CXFRateLimitFilter.cacheConfiguration(props.getRateLimit())); + } + + private static CXFRateLimitFilter 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)); + + CXFRateLimitFilter filter = new CXFRateLimitFilter(props, cache(props)); + ReflectionTestUtils.setField(filter, "request", request); + return filter; + } + + @Test + void blocksWhenClientExceedsLimit() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("10.0.0.10"); + + CXFRateLimitFilter filter = filter(request); + ContainerRequestContext requestContext = org.mockito.Mockito.mock(ContainerRequestContext.class); + + filter.filter(requestContext); + filter.filter(requestContext); + verify(requestContext, never()).abortWith(org.mockito.Mockito.any()); + + filter.filter(requestContext); + + ArgumentCaptor 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"); + + CXFRateLimitFilter trustedProxyFilter = new CXFRateLimitFilter(props, cache(props)); + 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"); + + CXFRateLimitFilter untrustedFilter = new CXFRateLimitFilter(props, cache(props)); + 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"); + + CXFRateLimitFilter filter = new CXFRateLimitFilter(props, cache(props)); + ReflectionTestUtils.setField(filter, "request", request); + ContainerRequestContext requestContext = org.mockito.Mockito.mock(ContainerRequestContext.class); + + filter.filter(requestContext); + filter.filter(requestContext); + + verify(requestContext, never()).abortWith(org.mockito.Mockito.any()); + } +} From 91cc84deb8c761e6e5ce7b595afdd0689dc2f457 Mon Sep 17 00:00:00 2001 From: Massimiliano Perrone Date: Thu, 11 Jun 2026 14:16:34 +0200 Subject: [PATCH 2/7] [SYNCOPE-1976] Move REST rate limit cache setup to Spring context --- .../core/rest/cxf/CXFRateLimitFilter.java | 18 +------------ .../core/rest/cxf/IdRepoRESTCXFContext.java | 22 +++++++++++++++- .../syncope/core/rest/cxf/RESTProperties.java | 8 +++--- ... => CXFRateLimitPropertiesFilterTest.java} | 25 ++++++++++++++++--- 4 files changed, 48 insertions(+), 25 deletions(-) rename core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/{CXFRateLimitFilterTest.java => CXFRateLimitPropertiesFilterTest.java} (80%) diff --git a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/CXFRateLimitFilter.java b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/CXFRateLimitFilter.java index 6efb2e7dcbc..f01ea99913e 100644 --- a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/CXFRateLimitFilter.java +++ b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/CXFRateLimitFilter.java @@ -32,10 +32,7 @@ import java.time.Duration; import java.util.Arrays; import java.util.Optional; -import java.util.concurrent.TimeUnit; import javax.cache.Cache; -import javax.cache.configuration.MutableConfiguration; -import javax.cache.expiry.TouchedExpiryPolicy; import org.apache.commons.lang3.StringUtils; @Provider @@ -54,14 +51,7 @@ public record ClientWindow(long windowStartMillis, int count, long lockedUntilMi private static final long serialVersionUID = -473897805205955157L; } - public static MutableConfiguration cacheConfiguration(final RESTProperties.RateLimit props) { - return new MutableConfiguration(). - setTypes(String.class, ClientWindow.class). - setExpiryPolicyFactory(TouchedExpiryPolicy.factoryOf( - new javax.cache.expiry.Duration(TimeUnit.MILLISECONDS, cacheExpiryMillis(props)))); - } - - protected final RESTProperties.RateLimit props; + protected final RESTProperties.RateLimitProperties props; protected final Cache clients; @@ -165,12 +155,6 @@ protected long toMillis(final Duration duration) { return Math.max(1L, Optional.ofNullable(duration).orElse(Duration.ofMinutes(1)).toMillis()); } - protected static long cacheExpiryMillis(final RESTProperties.RateLimit props) { - long windowMillis = Optional.ofNullable(props.getWindow()).orElse(Duration.ofMinutes(1)).toMillis(); - long lockMillis = Optional.ofNullable(props.getLock()).orElse(Duration.ofMinutes(1)).toMillis(); - return Math.max(1L, Math.max(windowMillis, lockMillis)); - } - 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/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 6e2703e9570..4194a8b280d 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,12 +20,17 @@ 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; @@ -247,7 +252,22 @@ public Cache cxfRateLimitCache( return cacheManager.createCache( CXFRateLimitFilter.CACHE_NAME, - CXFRateLimitFilter.cacheConfiguration(props.getRateLimit())); + cxfRateLimitCacheConfiguration(props.getRateLimit())); + } + + protected MutableConfiguration cxfRateLimitCacheConfiguration( + final RESTProperties.RateLimitProperties props) { + + return new MutableConfiguration(). + setTypes(String.class, CXFRateLimitFilter.ClientWindow.class). + setExpiryPolicyFactory(TouchedExpiryPolicy.factoryOf( + new javax.cache.expiry.Duration(TimeUnit.MILLISECONDS, cxfRateLimitCacheExpiryMillis(props)))); + } + + protected long cxfRateLimitCacheExpiryMillis(final RESTProperties.RateLimitProperties props) { + long windowMillis = Optional.ofNullable(props.getWindow()).orElse(Duration.ofMinutes(1)).toMillis(); + long lockMillis = Optional.ofNullable(props.getLock()).orElse(Duration.ofMinutes(1)).toMillis(); + return Math.max(1L, Math.max(windowMillis, lockMillis)); } @ConditionalOnMissingBean(name = { "openApiCustomizer", "syncopeOpenApiCustomizer" }) 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 0dcc4feb680..30944249b94 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 @@ -32,17 +32,17 @@ public class RESTProperties { private final ExecutorProperties batchExecutor = new ExecutorProperties(); @NestedConfigurationProperty - private final RateLimit rateLimit = new RateLimit(); + private final RateLimitProperties rateLimitProperties = new RateLimitProperties(); public ExecutorProperties getBatchExecutor() { return batchExecutor; } - public RateLimit getRateLimit() { - return rateLimit; + public RateLimitProperties getRateLimit() { + return rateLimitProperties; } - public static class RateLimit { + public static class RateLimitProperties { private boolean enabled; diff --git a/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/CXFRateLimitFilterTest.java b/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/CXFRateLimitPropertiesFilterTest.java similarity index 80% rename from core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/CXFRateLimitFilterTest.java rename to core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/CXFRateLimitPropertiesFilterTest.java index 2a5ac8a8cb5..61a2ff6c225 100644 --- a/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/CXFRateLimitFilterTest.java +++ b/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/CXFRateLimitPropertiesFilterTest.java @@ -26,20 +26,39 @@ import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.TimeUnit; import java.util.UUID; import javax.cache.Cache; import javax.cache.Caching; +import javax.cache.configuration.MutableConfiguration; +import javax.cache.expiry.TouchedExpiryPolicy; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.test.util.ReflectionTestUtils; -class CXFRateLimitFilterTest { +class CXFRateLimitPropertiesFilterTest { private static Cache cache(final RESTProperties props) { return Caching.getCachingProvider().getCacheManager().createCache( - CXFRateLimitFilterTest.class.getName() + '-' + UUID.randomUUID(), - CXFRateLimitFilter.cacheConfiguration(props.getRateLimit())); + CXFRateLimitPropertiesFilterTest.class.getName() + '-' + UUID.randomUUID(), + cacheConfiguration(props.getRateLimit())); + } + + private static MutableConfiguration cacheConfiguration( + final RESTProperties.RateLimitProperties props) { + + return new MutableConfiguration(). + setTypes(String.class, CXFRateLimitFilter.ClientWindow.class). + setExpiryPolicyFactory(TouchedExpiryPolicy.factoryOf( + new javax.cache.expiry.Duration(TimeUnit.MILLISECONDS, cacheExpiryMillis(props)))); + } + + private static long cacheExpiryMillis(final RESTProperties.RateLimitProperties props) { + long windowMillis = Optional.ofNullable(props.getWindow()).orElse(Duration.ofMinutes(1)).toMillis(); + long lockMillis = Optional.ofNullable(props.getLock()).orElse(Duration.ofMinutes(1)).toMillis(); + return Math.max(1L, Math.max(windowMillis, lockMillis)); } private static CXFRateLimitFilter filter(final MockHttpServletRequest request) { From cad83326dc46eced04f662338ec630081d3353b4 Mon Sep 17 00:00:00 2001 From: Massimiliano Perrone Date: Thu, 11 Jun 2026 15:07:34 +0200 Subject: [PATCH 3/7] [SYNCOPE-1976] Adjust REST rate limit test imports --- .../syncope/core/rest/cxf/CXFRateLimitPropertiesFilterTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/CXFRateLimitPropertiesFilterTest.java b/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/CXFRateLimitPropertiesFilterTest.java index 61a2ff6c225..465dc239e7e 100644 --- a/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/CXFRateLimitPropertiesFilterTest.java +++ b/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/CXFRateLimitPropertiesFilterTest.java @@ -27,8 +27,8 @@ import jakarta.ws.rs.core.Response; import java.time.Duration; import java.util.Optional; -import java.util.concurrent.TimeUnit; import java.util.UUID; +import java.util.concurrent.TimeUnit; import javax.cache.Cache; import javax.cache.Caching; import javax.cache.configuration.MutableConfiguration; From 30d6598599a5f10ea3f0afd45f981add1b082149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francesco=20Chicchiricc=C3=B2?= Date: Mon, 15 Jun 2026 15:27:52 +0200 Subject: [PATCH 4/7] Cleanup --- .../core/rest/cxf/IdRepoRESTCXFContext.java | 47 ++++++++----------- .../syncope/core/rest/cxf/RESTProperties.java | 28 +++++------ ...eLimitFilter.java => RateLimitFilter.java} | 19 ++++---- ...lterTest.java => RateLimitFilterTest.java} | 41 ++++------------ .../AuthenticationAttemptThrottler.java | 4 +- .../SyncopeBasicAuthenticationEntryPoint.java | 8 ++-- ...copeBasicAuthenticationEntryPointTest.java | 24 ++++++---- .../fit/core/AuthenticationITCase.java | 8 ++-- 8 files changed, 76 insertions(+), 103 deletions(-) rename core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/{CXFRateLimitFilter.java => RateLimitFilter.java} (89%) rename core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/{CXFRateLimitPropertiesFilterTest.java => RateLimitFilterTest.java} (69%) 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 4194a8b280d..7196be2577b 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 @@ -236,38 +236,31 @@ public AddETagFilter addETagFilter() { @ConditionalOnMissingBean @Bean - public CXFRateLimitFilter cxfRateLimitFilter( + public RateLimitFilter rateLimitFilter( final RESTProperties props, - @Qualifier(CXFRateLimitFilter.CACHE_NAME) - final Cache cxfRateLimitCache) { + @Qualifier(RateLimitFilter.CACHE) + final Cache cxfRateLimitCache) { - return new CXFRateLimitFilter(props, cxfRateLimitCache); + return new RateLimitFilter(props, cxfRateLimitCache); } - @ConditionalOnMissingBean(name = CXFRateLimitFilter.CACHE_NAME) - @Bean(name = CXFRateLimitFilter.CACHE_NAME) - public Cache cxfRateLimitCache( + @ConditionalOnMissingBean(name = RateLimitFilter.CACHE) + @Bean(name = RateLimitFilter.CACHE) + public Cache rateLimitCache( final CacheManager cacheManager, - final RESTProperties props) { + final RESTProperties restProperties) { - return cacheManager.createCache( - CXFRateLimitFilter.CACHE_NAME, - cxfRateLimitCacheConfiguration(props.getRateLimit())); - } - - protected MutableConfiguration cxfRateLimitCacheConfiguration( - final RESTProperties.RateLimitProperties props) { - - return new MutableConfiguration(). - setTypes(String.class, CXFRateLimitFilter.ClientWindow.class). - setExpiryPolicyFactory(TouchedExpiryPolicy.factoryOf( - new javax.cache.expiry.Duration(TimeUnit.MILLISECONDS, cxfRateLimitCacheExpiryMillis(props)))); - } + 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)); - protected long cxfRateLimitCacheExpiryMillis(final RESTProperties.RateLimitProperties props) { - long windowMillis = Optional.ofNullable(props.getWindow()).orElse(Duration.ofMinutes(1)).toMillis(); - long lockMillis = Optional.ofNullable(props.getLock()).orElse(Duration.ofMinutes(1)).toMillis(); - return Math.max(1L, Math.max(windowMillis, lockMillis)); + return cacheManager.createCache(RateLimitFilter.CACHE, + new MutableConfiguration(). + setTypes(String.class, RateLimitFilter.ClientWindow.class). + setExpiryPolicyFactory(TouchedExpiryPolicy.factoryOf( + new javax.cache.expiry.Duration(TimeUnit.MILLISECONDS, expiry)))); } @ConditionalOnMissingBean(name = { "openApiCustomizer", "syncopeOpenApiCustomizer" }) @@ -321,7 +314,7 @@ public Server restContainer( final List services, final AddETagFilter addETagFilter, final AddDomainFilter addDomainFilter, - final CXFRateLimitFilter cxfRateLimitFilter, + final RateLimitFilter rateLimitFilter, final ContextProvider searchContextProvider, final JacksonJsonProvider jsonProvider, final DateParamConverterProvider dateParamConverterProvider, @@ -352,7 +345,7 @@ public Server restContainer( jsonProvider, restServiceExceptionMapper, searchContextProvider, - cxfRateLimitFilter, + 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 30944249b94..a7ddd4d04f3 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 @@ -28,20 +28,6 @@ @ConfigurationProperties("rest") public class RESTProperties { - @NestedConfigurationProperty - private final ExecutorProperties batchExecutor = new ExecutorProperties(); - - @NestedConfigurationProperty - private final RateLimitProperties rateLimitProperties = new RateLimitProperties(); - - public ExecutorProperties getBatchExecutor() { - return batchExecutor; - } - - public RateLimitProperties getRateLimit() { - return rateLimitProperties; - } - public static class RateLimitProperties { private boolean enabled; @@ -106,4 +92,18 @@ public Set 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/CXFRateLimitFilter.java b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RateLimitFilter.java similarity index 89% rename from core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/CXFRateLimitFilter.java rename to core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RateLimitFilter.java index f01ea99913e..fe334fa9aa8 100644 --- a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/CXFRateLimitFilter.java +++ b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RateLimitFilter.java @@ -38,17 +38,18 @@ @Provider @PreMatching @Priority(Priorities.AUTHENTICATION - 100) -public class CXFRateLimitFilter implements ContainerRequestFilter { +public class RateLimitFilter implements ContainerRequestFilter { - public static final String CACHE_NAME = - "org.apache.syncope.core.rest.cxf.CXFRateLimitFilter"; + 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; @@ -58,7 +59,7 @@ public record ClientWindow(long windowStartMillis, int count, long lockedUntilMi @Context protected HttpServletRequest request; - public CXFRateLimitFilter(final RESTProperties props, final Cache clients) { + public RateLimitFilter(final RESTProperties props, final Cache clients) { this.props = props.getRateLimit(); this.clients = clients; } @@ -116,7 +117,7 @@ protected String clientAddress() { if (props.getTrustedProxies().contains(remoteAddress)) { String forwardedFor = Optional.ofNullable(request). map(req -> req.getHeader(props.getForwardedForHeader())). - flatMap(CXFRateLimitFilter::firstForwardedFor). + flatMap(RateLimitFilter::firstForwardedFor). orElse(null); if (StringUtils.isNotBlank(forwardedFor)) { return forwardedFor; @@ -137,11 +138,7 @@ protected String remoteAddress() { } protected Optional requestRemoteAddress() { - try { - return Optional.ofNullable(request).map(HttpServletRequest::getRemoteAddr); - } catch (NullPointerException e) { - return Optional.empty(); - } + return Optional.ofNullable(request).map(HttpServletRequest::getRemoteAddr); } protected static Optional firstForwardedFor(final String header) { @@ -151,7 +148,7 @@ protected static Optional firstForwardedFor(final String header) { findFirst(); } - protected long toMillis(final Duration duration) { + protected static long toMillis(final Duration duration) { return Math.max(1L, Optional.ofNullable(duration).orElse(Duration.ofMinutes(1)).toMillis()); } diff --git a/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/CXFRateLimitPropertiesFilterTest.java b/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/RateLimitFilterTest.java similarity index 69% rename from core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/CXFRateLimitPropertiesFilterTest.java rename to core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/RateLimitFilterTest.java index 465dc239e7e..b9539e5e156 100644 --- a/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/CXFRateLimitPropertiesFilterTest.java +++ b/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/RateLimitFilterTest.java @@ -26,49 +26,28 @@ import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; import java.time.Duration; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.TimeUnit; import javax.cache.Cache; import javax.cache.Caching; import javax.cache.configuration.MutableConfiguration; -import javax.cache.expiry.TouchedExpiryPolicy; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.test.util.ReflectionTestUtils; -class CXFRateLimitPropertiesFilterTest { +class RateLimitFilterTest { - private static Cache cache(final RESTProperties props) { - return Caching.getCachingProvider().getCacheManager().createCache( - CXFRateLimitPropertiesFilterTest.class.getName() + '-' + UUID.randomUUID(), - cacheConfiguration(props.getRateLimit())); - } - - private static MutableConfiguration cacheConfiguration( - final RESTProperties.RateLimitProperties props) { - - return new MutableConfiguration(). - setTypes(String.class, CXFRateLimitFilter.ClientWindow.class). - setExpiryPolicyFactory(TouchedExpiryPolicy.factoryOf( - new javax.cache.expiry.Duration(TimeUnit.MILLISECONDS, cacheExpiryMillis(props)))); - } - - private static long cacheExpiryMillis(final RESTProperties.RateLimitProperties props) { - long windowMillis = Optional.ofNullable(props.getWindow()).orElse(Duration.ofMinutes(1)).toMillis(); - long lockMillis = Optional.ofNullable(props.getLock()).orElse(Duration.ofMinutes(1)).toMillis(); - return Math.max(1L, Math.max(windowMillis, lockMillis)); - } + private static final Cache CACHE = + Caching.getCachingProvider().getCacheManager().createCache( + RateLimitFilter.CACHE, new MutableConfiguration<>()); - private static CXFRateLimitFilter filter(final MockHttpServletRequest request) { + 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)); - CXFRateLimitFilter filter = new CXFRateLimitFilter(props, cache(props)); + RateLimitFilter filter = new RateLimitFilter(props, CACHE); ReflectionTestUtils.setField(filter, "request", request); return filter; } @@ -78,7 +57,7 @@ void blocksWhenClientExceedsLimit() { MockHttpServletRequest request = new MockHttpServletRequest(); request.setRemoteAddr("10.0.0.10"); - CXFRateLimitFilter filter = filter(request); + RateLimitFilter filter = filter(request); ContainerRequestContext requestContext = org.mockito.Mockito.mock(ContainerRequestContext.class); filter.filter(requestContext); @@ -104,7 +83,7 @@ void usesForwardedForOnlyFromTrustedProxy() { trustedProxyRequest.setRemoteAddr("127.0.0.1"); trustedProxyRequest.addHeader("X-Forwarded-For", "203.0.113.10, 198.51.100.20"); - CXFRateLimitFilter trustedProxyFilter = new CXFRateLimitFilter(props, cache(props)); + RateLimitFilter trustedProxyFilter = new RateLimitFilter(props, CACHE); ReflectionTestUtils.setField(trustedProxyFilter, "request", trustedProxyRequest); assertEquals("203.0.113.10", trustedProxyFilter.clientAddress()); @@ -112,7 +91,7 @@ void usesForwardedForOnlyFromTrustedProxy() { untrustedRequest.setRemoteAddr("198.51.100.30"); untrustedRequest.addHeader("X-Forwarded-For", "203.0.113.10"); - CXFRateLimitFilter untrustedFilter = new CXFRateLimitFilter(props, cache(props)); + RateLimitFilter untrustedFilter = new RateLimitFilter(props, CACHE); ReflectionTestUtils.setField(untrustedFilter, "request", untrustedRequest); assertEquals("198.51.100.30", untrustedFilter.clientAddress()); } @@ -127,7 +106,7 @@ void skipsExcludedRemoteAddress() { MockHttpServletRequest request = new MockHttpServletRequest(); request.setRemoteAddr("10.0.0.20"); - CXFRateLimitFilter filter = new CXFRateLimitFilter(props, cache(props)); + RateLimitFilter filter = new RateLimitFilter(props, CACHE); ReflectionTestUtils.setField(filter, "request", request); ContainerRequestContext requestContext = org.mockito.Mockito.mock(ContainerRequestContext.class); 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 e552a70c3f7..afafd89d292 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 @@ public class AuthenticationAttemptThrottler { + public static final String CACHE = "AuthenticationAttemptCache"; + public record Attempts(Deque failures, long blockedUntil) implements Serializable { private static final long serialVersionUID = 8023582605543650484L; @@ -41,8 +43,6 @@ private Attempts() { } } - 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 9693780dbd3..33cb97081fd 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 @@ */ 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 void commence(final HttpServletRequest request, final HttpServletResponse 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 191b6b00289..04d9714e37d 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 @@ void rateLimitAuthenticationExceptionReturnsTooManyRequests() throws Exception { 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 @@ public void badCredentialsExposeGenericErrorInfo() throws Exception { 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 @@ public void missingUserExposeGenericErrorInfo() throws Exception { 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 @@ public void disabledUserExposeGenericErrorInfo() throws Exception { 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 @@ public void genericErrorInfoCanBeConfigured() throws Exception { } @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 529b4762630..a403d8bc220 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 void authenticationFailureErrorInfoIsGeneric() { 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("genericAuthError@syncope.apache.org"); 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)); } From a6d4295d3bbfbafe1139ea31d13ba6539c613722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francesco=20Chicchiricc=C3=B2?= Date: Mon, 15 Jun 2026 15:34:22 +0200 Subject: [PATCH 5/7] Fixing imports --- .../syncope/core/rest/cxf/RateLimitFilterTest.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 index b9539e5e156..833aaaab614 100644 --- 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 @@ -19,6 +19,8 @@ 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; @@ -58,11 +60,11 @@ void blocksWhenClientExceedsLimit() { request.setRemoteAddr("10.0.0.10"); RateLimitFilter filter = filter(request); - ContainerRequestContext requestContext = org.mockito.Mockito.mock(ContainerRequestContext.class); + ContainerRequestContext requestContext = mock(ContainerRequestContext.class); filter.filter(requestContext); filter.filter(requestContext); - verify(requestContext, never()).abortWith(org.mockito.Mockito.any()); + verify(requestContext, never()).abortWith(any()); filter.filter(requestContext); @@ -108,11 +110,11 @@ void skipsExcludedRemoteAddress() { RateLimitFilter filter = new RateLimitFilter(props, CACHE); ReflectionTestUtils.setField(filter, "request", request); - ContainerRequestContext requestContext = org.mockito.Mockito.mock(ContainerRequestContext.class); + ContainerRequestContext requestContext = mock(ContainerRequestContext.class); filter.filter(requestContext); filter.filter(requestContext); - verify(requestContext, never()).abortWith(org.mockito.Mockito.any()); + verify(requestContext, never()).abortWith(any()); } } From 89603e15f4ea494792d184fe7c274470e361e864 Mon Sep 17 00:00:00 2001 From: Massimiliano Perrone Date: Mon, 15 Jun 2026 15:55:31 +0200 Subject: [PATCH 6/7] [SYNCOPE-1976] Document REST rate limit address configuration --- .../asciidoc/reference-guide/usage/core.adoc | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/main/asciidoc/reference-guide/usage/core.adoc b/src/main/asciidoc/reference-guide/usage/core.adoc index 6533ae2292c..f27dc465f03 100644 --- a/src/main/asciidoc/reference-guide/usage/core.adoc +++ b/src/main/asciidoc/reference-guide/usage/core.adoc @@ -48,6 +48,73 @@ protocol://host:port/syncope/ .... ==== +[NOTE] +.REST Rate Limiting +==== +Syncope can apply rate limiting 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 Syncope is deployed behind a reverse proxy, configure the proxy address under +`rest.rateLimit.trustedProxies` to let Syncope identify the original client from the configured forwarded-for +header. 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, Syncope returns: +[source] +---- +HTTP/1.1 429 Too Many Requests +Retry-After: +---- +The rate limit state is stored through the configured JCache provider. With a distributed JCache provider, limits can be shared across cluster nodes; with a local provider, limits apply only to the current JVM. +==== + ==== REST Authentication and Authorization The <> authentication and authorization is based on https://spring.io/projects/spring-security[Spring Security^]. From b696c01a6a3e8f1fc1a5701bfc1170b8b1df3b9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francesco=20Chicchiricc=C3=B2?= Date: Mon, 15 Jun 2026 16:19:08 +0200 Subject: [PATCH 7/7] Reflow --- .../asciidoc/reference-guide/usage/core.adoc | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/main/asciidoc/reference-guide/usage/core.adoc b/src/main/asciidoc/reference-guide/usage/core.adoc index f27dc465f03..da64d10609a 100644 --- a/src/main/asciidoc/reference-guide/usage/core.adoc +++ b/src/main/asciidoc/reference-guide/usage/core.adoc @@ -48,12 +48,8 @@ protocol://host:port/syncope/ .... ==== -[NOTE] -.REST Rate Limiting -==== -Syncope can apply rate limiting to REST requests before -authentication and -request processing. +==== 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: @@ -91,9 +87,10 @@ 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 Syncope is deployed behind a reverse proxy, configure the proxy address under -`rest.rateLimit.trustedProxies` to let Syncope identify the original client from the configured forwarded-for -header. Add addresses to `rest.rateLimit.excludedAddresses` only for callers that should bypass rate limiting +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: @@ -106,14 +103,12 @@ 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, Syncope returns: +When the limit is exceeded, the following HTTP response is returned: [source] ---- HTTP/1.1 429 Too Many Requests Retry-After: ---- -The rate limit state is stored through the configured JCache provider. With a distributed JCache provider, limits can be shared across cluster nodes; with a local provider, limits apply only to the current JVM. -==== ==== REST Authentication and Authorization