This is an automated email from the ASF dual-hosted git repository.
fpapon pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/shiro.git
The following commit(s) were added to refs/heads/main by this push:
new 76ccdeb61 enh: added replay whitelist and blacklist check before
resubmitting a form after relogin (#2826)
76ccdeb61 is described below
commit 76ccdeb610b139a25fc42ae38c9663469619bc75
Author: Lenny Primak <[email protected]>
AuthorDate: Sun Jul 5 14:02:20 2026 -0500
enh: added replay whitelist and blacklist check before resubmitting a form
after relogin (#2826)
---
.../shiro/mgt/AbstractRememberMeManager.java | 2 +-
.../testing/jakarta/ee/PropertyPrincipal.java | 4 +-
.../shiro/ee/filters/FormResubmitSupport.java | 137 ++++++++++++++++++++-
.../shiro/ee/filters/FormResubmitValidator.java | 59 +++++++++
.../apache/shiro/ee/filters/FormSupportTest.java | 37 ++++++
.../shiro/web/filter/authc/NoAccessFilter.java | 12 ++
6 files changed, 247 insertions(+), 4 deletions(-)
diff --git
a/core/src/main/java/org/apache/shiro/mgt/AbstractRememberMeManager.java
b/core/src/main/java/org/apache/shiro/mgt/AbstractRememberMeManager.java
index da4de8962..d08eedafd 100644
--- a/core/src/main/java/org/apache/shiro/mgt/AbstractRememberMeManager.java
+++ b/core/src/main/java/org/apache/shiro/mgt/AbstractRememberMeManager.java
@@ -74,7 +74,7 @@ import java.util.function.Supplier;
* @since 0.9
*/
public abstract class AbstractRememberMeManager implements RememberMeManager {
- protected record RememberedIdentity(PrincipalCollection principals,
Instant creationTime) implements Serializable {
+ public record RememberedIdentity(PrincipalCollection principals, Instant
creationTime) implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
}
diff --git
a/integration-tests/jakarta-ee/src/main/java/org/apache/shiro/testing/jakarta/ee/PropertyPrincipal.java
b/integration-tests/jakarta-ee/src/main/java/org/apache/shiro/testing/jakarta/ee/PropertyPrincipal.java
index 1cbc047d7..e680dd8c9 100644
---
a/integration-tests/jakarta-ee/src/main/java/org/apache/shiro/testing/jakarta/ee/PropertyPrincipal.java
+++
b/integration-tests/jakarta-ee/src/main/java/org/apache/shiro/testing/jakarta/ee/PropertyPrincipal.java
@@ -18,11 +18,13 @@ import java.io.Serializable;
import jakarta.enterprise.inject.Vetoed;
import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
import lombok.Getter;
@Getter
-@AllArgsConstructor
@Vetoed
+@EqualsAndHashCode
+@AllArgsConstructor
public class PropertyPrincipal implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
diff --git
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitSupport.java
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitSupport.java
index 02c9164de..1aa637d80 100644
---
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitSupport.java
+++
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitSupport.java
@@ -35,6 +35,7 @@ import static
org.apache.shiro.ee.filters.FormResubmitSupportCookies.deleteCooki
import static
org.apache.shiro.ee.filters.FormResubmitSupportCookies.getCookieAge;
import static
org.apache.shiro.ee.filters.FormResubmitSupportCookies.getSessionCookieName;
import java.net.URISyntaxException;
+import java.time.Duration;
import java.util.Collections;
import org.apache.shiro.crypto.CryptoException;
import org.apache.shiro.ee.filters.Forms.FallbackPredicate;
@@ -55,6 +56,7 @@ import java.util.Optional;
import java.util.UUID;
import static java.util.function.Predicate.not;
import static org.apache.shiro.ee.listeners.IniEnvironment.hasFacesContext;
+import static
org.apache.shiro.web.filter.authc.NoAccessFilter.FORM_RESUBMIT_CHECK_SERVLET_PATH;
import static
org.apache.shiro.web.mgt.CookieRememberMeManager.DEFAULT_REMEMBER_ME_COOKIE_NAME;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -72,6 +74,7 @@ import lombok.SneakyThrows;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
+import org.apache.shiro.cache.Cache;
import org.apache.shiro.lang.codec.Base64;
import org.apache.shiro.mgt.AbstractRememberMeManager;
import org.apache.shiro.mgt.DefaultSecurityManager;
@@ -94,6 +97,8 @@ public class FormResubmitSupport {
static final String SHIRO_FORM_DATA_KEY = "org.apache.shiro.form-data-key";
static final String SESSION_EXPIRED_PARAMETER =
"org.apache.shiro.sessionExpired";
static final String FORM_IS_RESUBMITTED =
"org.apache.shiro.form-is-resubmitted";
+ static final String FORM_RESUBMIT_WHITELIST =
"org.apache.shiro.form-resubmit-whitelist";
+ static final String FORM_RESUBMIT_BLACKLIST =
"org.apache.shiro.form-resubmit-blacklist";
// encoded view state
private static final String FACES_VIEW_STATE = "jakarta.faces.ViewState";
private static final String FACES_VIEW_STATE_EQUALS = FACES_VIEW_STATE +
"=";
@@ -113,6 +118,17 @@ public class FormResubmitSupport {
private static final Optional<String> RESUBMIT_HOST =
Optional.ofNullable(System.getProperty(FORM_RESUBMIT_HOST));
private static final Optional<Integer> RESUBMIT_PORT =
Optional.ofNullable(System.getProperty(FORM_RESUBMIT_PORT))
.map(Integer::valueOf);
+ private static final String FORM_RESUBMIT_WHITE_LIST_MAX_SIZE =
"org.apache.shiro.form-resubmit-whitelist-max-size";
+ private static final Optional<Integer> RESUBMIT_WHITE_LIST_MAX_SIZE =
+
Optional.ofNullable(System.getProperty(FORM_RESUBMIT_WHITE_LIST_MAX_SIZE)).map(Integer::valueOf);
+ private static final String FORM_RESUBMIT_BLACK_LIST_MAX_SIZE =
"org.apache.shiro.form-resubmit-blacklist-max-size";
+ private static final Optional<Integer> RESUBMIT_BLACK_LIST_MAX_SIZE =
+
Optional.ofNullable(System.getProperty(FORM_RESUBMIT_BLACK_LIST_MAX_SIZE)).map(Integer::valueOf);
+ private static final String FORM_RESUBMIT_BLACK_LIST_TTL_SECONDS =
+ "org.apache.shiro.form-resubmit-blacklist-ttl-seconds";
+ private static final Optional<Long> RESUBMIT_BLACK_LIST_TTL_SECONDS =
+
Optional.ofNullable(System.getProperty(FORM_RESUBMIT_BLACK_LIST_TTL_SECONDS)).map(Long::valueOf);
+ private static final long DEFAULT_RESUBMIT_BLACK_LIST_TTL_SECONDS = 60L;
static class HttpMethod {
static final String GET = "GET";
@@ -418,6 +434,9 @@ public class FormResubmitSupport {
}
URI overriddenRequestURI =
overrideSavedRequestURI(URI.create(savedRequest));
HttpClient client = buildHttpClient(overriddenRequestURI,
servletContext, originalRequest);
+ if (!checkWhitelist(servletContext, overriddenRequestURI, client)) {
+ return savedRequest;
+ }
HttpResponse<String> response;
PartialAjaxResult decodedFormData;
try {
@@ -460,6 +479,7 @@ public class FormResubmitSupport {
private static HttpRequest constructPostRequest(URI request, String body) {
return HttpRequest.newBuilder().uri(request)
+ .timeout(Duration.ofSeconds(5))
.POST(HttpRequest.BodyPublishers.ofString(body))
.headers(CONTENT_TYPE, APPLICATION_FORM_URLENCODED,
FORM_IS_RESUBMITTED, Boolean.TRUE.toString())
@@ -569,7 +589,120 @@ public class FormResubmitSupport {
}
}
}
- return HttpClient.newBuilder().cookieHandler(cookieManager).build();
+ return
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).cookieHandler(cookieManager).build();
+ }
+
+ private static boolean checkWhitelist(ServletContext servletContext, URI
savedRequestURI, HttpClient client) {
+ if (!isSecurityManagerTypeOf(getSecurityManager(),
DefaultSecurityManager.class)) {
+ log.warn("Shiro SecurityManager is not configured for form
resubmit whitelist caching");
+ return false;
+ }
+ DefaultSecurityManager dsm =
getSecurityManager(DefaultSecurityManager.class);
+ if (dsm.getCacheManager() == null) {
+ log.warn("Shiro Cache manager is not configured, cannot cache form
resubmit whitelist state");
+ return false;
+ }
+
+ Cache<String, Boolean> whitelist = getWhitelistCache(dsm);
+ Cache<String, Long> blacklist = getBlacklistCache(dsm);
+ String authority = savedRequestURI.getAuthority();
+
+ if (Boolean.TRUE.equals(whitelist.get(authority))) {
+ return true;
+ } else if (isBlacklisted(blacklist, authority)) {
+ log.debug("Form resubmit blacklist cache hit for {}",
savedRequestURI);
+ return false;
+ } else if (checkWhitelistClient(savedRequestURI,
servletContext.getContextPath(), client)) {
+ putWhitelistEntry(whitelist, authority);
+ blacklist.remove(authority);
+ return true;
+ }
+
+ putBlacklistEntry(blacklist, authority);
+ return false;
+ }
+
+ static Cache<String, Boolean> getWhitelistCache(DefaultSecurityManager
securityManager) {
+ return
securityManager.getCacheManager().getCache(FORM_RESUBMIT_WHITELIST);
+ }
+
+ static Cache<String, Long> getBlacklistCache(DefaultSecurityManager
securityManager) {
+ return
securityManager.getCacheManager().getCache(FORM_RESUBMIT_BLACKLIST);
+ }
+
+ private static void putWhitelistEntry(Cache<String, Boolean> whitelist,
String authority) {
+ if (whitelist.get(authority) == null) {
+ @SuppressWarnings("checkstyle:MagicNumber")
+ int maxSize = RESUBMIT_WHITE_LIST_MAX_SIZE.orElse(1000);
+ if (whitelist.size() >= maxSize) {
+ log.warn("Form resubmit whitelist exceeded max size of {}.
Clearing whitelist.", maxSize);
+ whitelist.clear();
+ }
+ }
+ whitelist.put(authority, Boolean.TRUE);
+ }
+
+ private static void putBlacklistEntry(Cache<String, Long> blacklist,
String authority) {
+ if (blacklist.get(authority) == null) {
+ @SuppressWarnings("checkstyle:MagicNumber")
+ int maxSize = RESUBMIT_BLACK_LIST_MAX_SIZE.orElse(1000);
+ if (blacklist.size() >= maxSize) {
+ log.warn("Form resubmit blacklist exceeded max size of {}.
Clearing blacklist.", maxSize);
+ blacklist.clear();
+ }
+ }
+ blacklist.put(authority, System.currentTimeMillis());
+ }
+
+ static boolean isBlacklisted(Cache<String, Long> blacklist, String
authority) {
+ long currentTimeMillis = System.currentTimeMillis();
+ return isBlacklisted(blacklist, authority,
+
Duration.ofSeconds(RESUBMIT_BLACK_LIST_TTL_SECONDS.orElse(DEFAULT_RESUBMIT_BLACK_LIST_TTL_SECONDS)),
+ currentTimeMillis);
+ }
+
+ static boolean isBlacklisted(Cache<String, Long> blacklist, String
authority,
+ Duration ttl, long currentTimeMillis) {
+ Long blacklistedAt = blacklist.get(authority);
+ if (blacklistedAt == null) {
+ return false;
+ }
+ boolean active = blacklistedAt >= currentTimeMillis
+ || currentTimeMillis - blacklistedAt < ttl.toMillis();
+ if (!active) {
+ blacklist.remove(authority);
+ }
+ return active;
+ }
+
+ private static boolean checkWhitelistClient(URI savedRequestURI, String
contextPath, HttpClient client) {
+ try {
+ var rememberMeManager = getRememberMeManager();
+ if (rememberMeManager == null ||
rememberMeManager.getCipherService() == null
+ || rememberMeManager.getSerializer() == null) {
+ log.warn("Form resubmit cipher service not available, unable
to decrypt - resubmit will not be available.");
+ return false;
+ }
+
+ var request = HttpRequest.newBuilder()
+
.uri(URI.create("%s://%s%s%s".formatted(savedRequestURI.getScheme(),
savedRequestURI.getAuthority(),
+ contextPath, FORM_RESUBMIT_CHECK_SERVLET_PATH)))
+ .timeout(Duration.ofSeconds(3)).GET().build();
+ var response = client.send(request,
HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() == OK &&
Objects.equals(decrypt(response.body(), rememberMeManager),
+
SecurityUtils.getSubject().getSession().getId().toString())) {
+ log.debug("Form resubmit whitelist check succeeded for {}",
savedRequestURI);
+ return true;
+ } else {
+ log.debug("Form resubmit whitelist check failed for {} with
status code {}",
+ savedRequestURI, response.statusCode());
+ }
+ } catch (IOException | InterruptedException e) {
+ log.debug("Form resubmit whitelist check failed for {} with
exception: {}",
+ savedRequestURI, e);
+ }
+ return false;
}
public static DefaultWebSessionManager
getNativeSessionManager(SecurityManager securityManager) {
@@ -584,7 +717,7 @@ public class FormResubmitSupport {
return rv;
}
- private static AbstractRememberMeManager getRememberMeManager() {
+ static AbstractRememberMeManager getRememberMeManager() {
if (isSecurityManagerTypeOf(getSecurityManager(),
DefaultSecurityManager.class)) {
var dsm = getSecurityManager(DefaultSecurityManager.class);
return (AbstractRememberMeManager) dsm.getRememberMeManager();
diff --git
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitValidator.java
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitValidator.java
new file mode 100644
index 000000000..85b8f70aa
--- /dev/null
+++
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitValidator.java
@@ -0,0 +1,59 @@
+/*
+ * 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.shiro.ee.filters;
+
+import jakarta.servlet.annotation.WebServlet;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.shiro.SecurityUtils;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import static
org.apache.shiro.ee.filters.FormResubmitSupport.getRememberMeManager;
+import static
org.apache.shiro.web.filter.authc.NoAccessFilter.FORM_RESUBMIT_CHECK_SERVLET_PATH;
+
+@Slf4j
+@WebServlet(name = "ShiroFormResubmitValidator", urlPatterns =
FORM_RESUBMIT_CHECK_SERVLET_PATH)
+public class FormResubmitValidator extends HttpServlet {
+ @Override
+ protected void doGet(HttpServletRequest request, HttpServletResponse
response) {
+ var session = SecurityUtils.getSubject().getSession(false);
+ if (session == null) {
+ response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+ return;
+ }
+
+ var rememberMeManager = getRememberMeManager();
+ if (rememberMeManager == null || rememberMeManager.getCipherService()
== null) {
+ response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+ } else {
+ try {
+ String encryptedSessionId =
rememberMeManager.getCipherService()
+
.encrypt(session.getId().toString().getBytes(StandardCharsets.UTF_8),
+ rememberMeManager.getEncryptionCipherKey()).toBase64();
+ response.getWriter().write(encryptedSessionId);
+ response.setStatus(HttpServletResponse.SC_OK);
+ } catch (IOException e) {
+ log.warn("Form resubmit verification: failed to write
encrypted principals to response", e);
+ response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+ }
+ }
+ }
+}
diff --git
a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormSupportTest.java
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormSupportTest.java
index 99dcfdb64..4ff4cfc5a 100644
---
a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormSupportTest.java
+++
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormSupportTest.java
@@ -14,6 +14,7 @@
package org.apache.shiro.ee.filters;
import org.apache.shiro.ee.filters.FormResubmitSupport.PartialAjaxResult;
+import org.apache.shiro.cache.MemoryConstrainedCacheManager;
import static
org.apache.shiro.ee.filters.FormResubmitSupport.FACES_SOURCE_PATTERN;
import static
org.apache.shiro.ee.filters.FormResubmitSupport.extractJSFNewViewState;
@@ -23,6 +24,7 @@ import static
org.apache.shiro.ee.filters.FormResubmitSupport.noJSFAjaxRequests;
import static
org.apache.shiro.ee.filters.FormResubmitSupportCookies.transformCookieHeader;
import java.net.URLDecoder;
+import java.time.Duration;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
@@ -38,12 +40,16 @@ import org.mockito.Mock;
import static org.mockito.Mockito.when;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.apache.shiro.mgt.DefaultSecurityManager;
/**
* Resubmit forms support
*/
@ExtendWith(MockitoExtension.class)
class FormSupportTest {
+ private static final long BLACKLISTED_AT = 1_000L;
+ private static final Duration BLACKLIST_TTL = Duration.ofSeconds(60);
+
@Mock
private HttpServletRequest request;
@@ -328,6 +334,37 @@ class FormSupportTest {
.isEqualTo(Map.of("JSESSIONID", "abc"));
}
+ @Test
+ @SuppressWarnings("checkstyle:MagicNumber")
+ void whitelistAndBlacklistUseShiroCacheManager() {
+ var securityManager = new DefaultSecurityManager();
+ securityManager.setCacheManager(new MemoryConstrainedCacheManager());
+
+ var whitelist = FormResubmitSupport.getWhitelistCache(securityManager);
+ var blacklist = FormResubmitSupport.getBlacklistCache(securityManager);
+
+ whitelist.put("good.example", Boolean.TRUE);
+ blacklist.put("bad.example", BLACKLISTED_AT);
+
+
assertThat(FormResubmitSupport.getWhitelistCache(securityManager).get("good.example")).isTrue();
+ assertThat(FormResubmitSupport.isBlacklisted(blacklist, "bad.example",
+ BLACKLIST_TTL, 1_500L)).isTrue();
+ }
+
+ @Test
+ @SuppressWarnings("checkstyle:MagicNumber")
+ void expiredBlacklistEntryIsRemovedFromShiroCache() {
+ var securityManager = new DefaultSecurityManager();
+ securityManager.setCacheManager(new MemoryConstrainedCacheManager());
+
+ var blacklist = FormResubmitSupport.getBlacklistCache(securityManager);
+ blacklist.put("expired.example", BLACKLISTED_AT);
+
+ assertThat(FormResubmitSupport.isBlacklisted(blacklist,
"expired.example",
+ BLACKLIST_TTL, 61_001L)).isFalse();
+ assertThat(blacklist.get("expired.example")).isNull();
+ }
+
private static String decode(String plain) {
return URLDecoder.decode(plain, StandardCharsets.UTF_8);
}
diff --git
a/web/src/main/java/org/apache/shiro/web/filter/authc/NoAccessFilter.java
b/web/src/main/java/org/apache/shiro/web/filter/authc/NoAccessFilter.java
index 8d6e13afb..db1fecdd5 100644
--- a/web/src/main/java/org/apache/shiro/web/filter/authc/NoAccessFilter.java
+++ b/web/src/main/java/org/apache/shiro/web/filter/authc/NoAccessFilter.java
@@ -18,6 +18,7 @@
*/
package org.apache.shiro.web.filter.authc;
+import jakarta.servlet.http.HttpServletRequest;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.web.util.WebUtils;
import org.slf4j.Logger;
@@ -31,6 +32,7 @@ import jakarta.servlet.ServletResponse;
* that do not match existing filter patterns.
*/
public class NoAccessFilter extends AuthenticatingFilter {
+ public static final String FORM_RESUBMIT_CHECK_SERVLET_PATH =
"/org.apache.shiro.form-resubmit-check";
private final Logger log = LoggerFactory.getLogger(NoAccessFilter.class);
@@ -45,4 +47,14 @@ public class NoAccessFilter extends AuthenticatingFilter {
protected AuthenticationToken createToken(ServletRequest request,
ServletResponse response) throws Exception {
return null;
}
+
+ @Override
+ protected boolean isAccessAllowed(ServletRequest request, ServletResponse
response, Object mappedValue) {
+ if (request instanceof HttpServletRequest) {
+ HttpServletRequest httpRequest = WebUtils.toHttp(request);
+ return httpRequest.getMethod().equals("GET")
+ &&
httpRequest.getServletPath().equals(FORM_RESUBMIT_CHECK_SERVLET_PATH);
+ }
+ return false;
+ }
}