Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
* @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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
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;
Expand All @@ -55,6 +56,7 @@
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;
Expand All @@ -72,6 +74,7 @@
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;
Expand All @@ -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 + "=";
Expand All @@ -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";
Expand Down Expand Up @@ -418,6 +434,9 @@ static String resubmitSavedForm(@NonNull String savedFormData, @NonNull String s
}
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 {
Expand Down Expand Up @@ -460,6 +479,7 @@ private static URI overrideSavedRequestURI(URI savedRequestURI) {

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())
Expand Down Expand Up @@ -569,7 +589,120 @@ private static HttpClient buildHttpClient(URI savedRequest, ServletContext servl
}
}
}
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) {
Expand All @@ -584,7 +717,7 @@ public static DefaultWebSessionManager getNativeSessionManager(SecurityManager s
return rv;
}

private static AbstractRememberMeManager getRememberMeManager() {
static AbstractRememberMeManager getRememberMeManager() {
if (isSecurityManagerTypeOf(getSecurityManager(), DefaultSecurityManager.class)) {
var dsm = getSecurityManager(DefaultSecurityManager.class);
return (AbstractRememberMeManager) dsm.getRememberMeManager();
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +24,7 @@
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;
Expand All @@ -38,12 +40,16 @@
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;

Expand Down Expand Up @@ -328,6 +334,37 @@ void parseCookies() {
.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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,6 +32,7 @@
* 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);

Expand All @@ -45,4 +47,14 @@ protected boolean onAccessDenied(ServletRequest request, ServletResponse respons
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;
}
}
Loading