You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@shiro.apache.org by lp...@apache.org on 2026/10/14 09:24:40 UTC
(shiro) branch main updated: [#2232] Resubmit without login (#2907)
This is an automated email from the ASF dual-hosted git repository.
lprimak 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 1b7b8b54c [#2232] Resubmit without login (#2907)
1b7b8b54c is described below
commit 1b7b8b54c1161cdef7767f67c204891ab72ba54b
Author: Lenny Primak <le...@flowlogix.com>
AuthorDate: Wed Oct 14 04:24:34 2026 -0500
[#2232] Resubmit without login (#2907)
---
.../jakarta/ee/ProtectedOmniViewScopedBean.java | 2 +-
.../testing/jakarta/ee/UnprotectedFormBean.java | 48 +++++++
.../src/main/webapp/shiro/unprotected/form.xhtml | 64 +++++++++
.../shiro/testing/jakarta/ee/ShiroAuthFormsIT.java | 57 +++++++-
support/jakarta-ee/README.adoc | 19 ++-
.../shiro/ee/filters/FormResubmitRequest.java | 23 +++-
.../shiro/ee/filters/FormResubmitResponse.java | 39 ++++++
.../shiro/ee/filters/FormResubmitSupport.java | 45 ++++++-
.../org/apache/shiro/ee/filters/ShiroFilter.java | 52 +++++---
.../ee/listeners/EnvironmentLoaderListener.java | 11 ++
.../ee/filters/DirectResubmitCandidateTest.java | 148 +++++++++++++++++++++
.../shiro/ee/filters/FormResubmitRequestTest.java | 71 ++++++++++
.../shiro/ee/filters/FormResubmitResponseTest.java | 14 ++
13 files changed, 564 insertions(+), 29 deletions(-)
diff --git a/integration-tests/jakarta-ee/src/main/java/org/apache/shiro/testing/jakarta/ee/ProtectedOmniViewScopedBean.java b/integration-tests/jakarta-ee/src/main/java/org/apache/shiro/testing/jakarta/ee/ProtectedOmniViewScopedBean.java
index c8720a1f5..24cf04834 100644
--- a/integration-tests/jakarta-ee/src/main/java/org/apache/shiro/testing/jakarta/ee/ProtectedOmniViewScopedBean.java
+++ b/integration-tests/jakarta-ee/src/main/java/org/apache/shiro/testing/jakarta/ee/ProtectedOmniViewScopedBean.java
@@ -28,7 +28,7 @@ import org.apache.shiro.authz.annotation.RequiresUser;
import org.omnifaces.cdi.ViewScoped;
/**
- * OmniFaces ViewScoped Shrito-protected beans
+ * OmniFaces ViewScoped Shiro-protected beans
*/
@ViewScoped
@Named
diff --git a/integration-tests/jakarta-ee/src/main/java/org/apache/shiro/testing/jakarta/ee/UnprotectedFormBean.java b/integration-tests/jakarta-ee/src/main/java/org/apache/shiro/testing/jakarta/ee/UnprotectedFormBean.java
new file mode 100644
index 000000000..1eb71352d
--- /dev/null
+++ b/integration-tests/jakarta-ee/src/main/java/org/apache/shiro/testing/jakarta/ee/UnprotectedFormBean.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed 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.testing.jakarta.ee;
+
+import jakarta.enterprise.inject.Model;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.shiro.ee.filters.Forms;
+import org.omnifaces.util.Faces;
+import org.omnifaces.util.Messages;
+
+/**
+ * backing bean for the anonymous (unprotected) form page
+ */
+@Model
+@Getter
+@Setter
+public class UnprotectedFormBean {
+ private String firstName;
+ private String lastName;
+ private String address;
+ private String city;
+
+ public void submit() {
+ Messages.addFlashGlobalInfo("Anonymous Form Submitted - firstName: {0}, lastName: {1}", firstName, lastName);
+ Forms.redirectToView();
+ }
+
+ public void submit2() {
+ if (Faces.isAjaxRequest()) {
+ Messages.addGlobalInfo("2nd Anonymous Form Submitted - Address: {0}, City: {1}", address, city);
+ } else {
+ Messages.addFlashGlobalInfo("2nd Anonymous Form Submitted - Address: {0}, City: {1}", address, city);
+ Forms.redirectToView();
+ }
+ }
+}
diff --git a/integration-tests/jakarta-ee/src/main/webapp/shiro/unprotected/form.xhtml b/integration-tests/jakarta-ee/src/main/webapp/shiro/unprotected/form.xhtml
new file mode 100644
index 000000000..a3fe37067
--- /dev/null
+++ b/integration-tests/jakarta-ee/src/main/webapp/shiro/unprotected/form.xhtml
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<!--
+ ~ 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.
+ -->
+ <html xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:f="jakarta.faces.core"
+ xmlns:jsf="jakarta.faces"
+ xmlns:p="jakarta.faces.passthrough"
+ xmlns:h="jakarta.faces.html">
+ <h:head>
+ <title>Anonymous Form Page</title>
+ </h:head>
+ <h:body>
+ <h:form id="firstForm">
+ First Name:
+ <h:inputText id="firstName" title="First Name" p:autofocus="true" value="#{unprotectedFormBean.firstName}"/>
+ <p/>
+ Last Name:
+ <h:inputText id="lastName" title="Last Name" value="#{unprotectedFormBean.lastName}"/>
+ <p/>
+ <h:commandButton id="submitFirst" value="Submit ..." action="#{unprotectedFormBean.submit}"/>
+ <h:messages id="messages"/>
+ </h:form>
+ <h:form id="secondForm">
+ Address:
+ <h:inputText id="address" value="#{unprotectedFormBean.address}"/>
+ <p/>
+ City:
+ <h:inputText id="city" value="#{unprotectedFormBean.city}"/>
+ <p/>
+ <h:commandButton id="submitSecond" value="Submit ..." action="#{unprotectedFormBean.submit2}">
+ <f:ajax execute="@form" render="@form"/>
+ </h:commandButton>
+ <h:messages id="messages"/>
+ </h:form>
+ <div jsf:rendered="#{authc.sessionExpired}" style="color: red;" id="sessionExpiredMessage">
+ Your Session Has Expired
+ </div>
+ <input type="button" id="invalidate" value="Invalidate session via Servlet"
+ onclick="invalidateSession('#{request.contextPath}/invalidatesession')" />
+ </h:body>
+ <script>
+ const invalidateSession = async (path) => {
+ const response = await fetch(path);
+ const strresp = await response.text();
+ alert('Session invalidated!');
+ };
+ </script>
+</html>
diff --git a/integration-tests/jakarta-ee/src/test/java/org/apache/shiro/testing/jakarta/ee/ShiroAuthFormsIT.java b/integration-tests/jakarta-ee/src/test/java/org/apache/shiro/testing/jakarta/ee/ShiroAuthFormsIT.java
index 8404a0847..096a0be84 100644
--- a/integration-tests/jakarta-ee/src/test/java/org/apache/shiro/testing/jakarta/ee/ShiroAuthFormsIT.java
+++ b/integration-tests/jakarta-ee/src/test/java/org/apache/shiro/testing/jakarta/ee/ShiroAuthFormsIT.java
@@ -48,6 +48,7 @@ import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.openqa.selenium.By;
+import org.openqa.selenium.Cookie;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
@@ -61,6 +62,7 @@ import org.openqa.selenium.support.ui.ExpectedConditions;
public class ShiroAuthFormsIT {
static final String DEPLOYMENT_DEV_MODE = "DevMode";
static final String DEPLOYMENT_PROD_MODE = "ProdMode";
+ private static final String NATIVE_SESSION_COOKIE_NAME = "native_session_cookie";
@Drone
private WebDriver webDriver;
@@ -111,6 +113,9 @@ public class ShiroAuthFormsIT {
@FindBy(id = "secondForm:messages")
private WebElement secondFormMessages;
+ @FindBy(id = "firstForm:messages")
+ private WebElement firstFormMessages;
+
@FindBy(id = "invalidate")
private WebElement invalidateSession;
@@ -191,7 +196,7 @@ public class ShiroAuthFormsIT {
}
login();
webDriver.manage().deleteCookieNamed(isShiroNativeSessionsIntegrationTest()
- ? "native_session_cookie" : DEFAULT_SESSION_ID_NAME);
+ ? NATIVE_SESSION_COOKIE_NAME : DEFAULT_SESSION_ID_NAME);
webDriver.navigate().refresh();
assertThat(webDriver.getTitle()).isEqualTo("Protected Page");
guardHttp(logout).click();
@@ -364,6 +369,39 @@ public class ShiroAuthFormsIT {
assertThat(secondFormMessages.getText()).isEqualTo("2nd Form Submitted - Address: LAX Airport, City: Los Angeles");
}
+ @Test
+ @OperateOnDeployment(DEPLOYMENT_DEV_MODE)
+ void nonAjaxAnonymousResubmit() {
+ webDriver.get(baseURL + "shiro/unprotected/form");
+ assertThat(webDriver.getTitle()).isEqualTo("Anonymous Form Page");
+ expireAnonymousSession();
+ firstName.sendKeys("Jack");
+ lastName.sendKeys("Frost");
+ guardHttp(submitFirst).click();
+ assertThat(webDriver.getTitle()).as("no login page").isEqualTo("Anonymous Form Page");
+ assertThat(firstFormMessages.getText()).isEqualTo("Anonymous Form Submitted - firstName: Jack, lastName: Frost");
+ }
+
+ @Test
+ @OperateOnDeployment(DEPLOYMENT_DEV_MODE)
+ void ajaxAnonymousResubmit() {
+ webDriver.get(baseURL + "shiro/unprotected/form");
+ address.sendKeys("1 Houston Street");
+ city.sendKeys("New York");
+ expireAnonymousSession();
+ guardAjax(submitSecond).click();
+ assertThat(webDriver.getTitle()).as("no login page").isEqualTo("Anonymous Form Page");
+ assertThat(secondFormMessages.getText())
+ .isEqualTo("2nd Anonymous Form Submitted - Address: 1 Houston Street, City: New York");
+ address.clear();
+ city.clear();
+ address.sendKeys("Workshop");
+ city.sendKeys("North Pole");
+ guardAjax(submitSecond).click();
+ assertThat(secondFormMessages.getText())
+ .isEqualTo("2nd Anonymous Form Submitted - Address: Workshop, City: North Pole");
+ }
+
@Test
@OperateOnDeployment(DEPLOYMENT_DEV_MODE)
void alreadyLoggedIn() {
@@ -380,6 +418,23 @@ public class ShiroAuthFormsIT {
guardHttp(login).click();
}
+ /**
+ * Simulates a timed-out anonymous session. Explicit invalidation of a native session also removes
+ * its cookie from the browser, unlike a timeout, after which the browser still sends the stale id,
+ * so the stale cookie is restored here.
+ */
+ private void expireAnonymousSession() {
+ var sessionCookie = webDriver.manage().getCookieNamed(NATIVE_SESSION_COOKIE_NAME);
+ invalidateSession.click();
+ waitGui(webDriver).until(ExpectedConditions.alertIsPresent());
+ webDriver.switchTo().alert().accept();
+ if (isShiroNativeSessionsIntegrationTest() && sessionCookie != null) {
+ // Firefox rejects re-adding the cookie with its original attributes (e.g. secure) over plain HTTP
+ webDriver.manage().addCookie(new Cookie(sessionCookie.getName(), sessionCookie.getValue(),
+ sessionCookie.getPath()));
+ }
+ }
+
@Deployment(testable = false, name = DEPLOYMENT_DEV_MODE)
public static WebArchive createDeployment() {
return createDeploymentDev("shiro-auth-forms.war");
diff --git a/support/jakarta-ee/README.adoc b/support/jakarta-ee/README.adoc
index 99f333586..936fbbee5 100644
--- a/support/jakarta-ee/README.adoc
+++ b/support/jakarta-ee/README.adoc
@@ -52,8 +52,7 @@ of applied to the login response, preserving its session cookies.
Saved form data is decoded with the request's character encoding, falling back
to the servlet context's and then UTF-8.
-A remembered subject's Faces Ajax submission, replayed in place when its
-session is gone, stays an Ajax request:
+A Faces Ajax submission replayed in place (see below) stays an Ajax request:
the saved partial request is replayed with the refreshed view state, and its
partial response is passed through to the waiting Ajax client, without a full
page refresh. Since the server-side view is rebuilt from scratch, the replay
@@ -68,6 +67,22 @@ to the saved request, as a failed full-page replay does. After a login flow,
the browser is on the login page instead, so the submission is replayed as a
full-page action and the browser is redirected to the saved request.
+=== Replay without a login flow
+
+When a POST arrives with a session id that no longer resolves to a session,
+and the subject is either remembered or anonymous, the form is replayed in
+place as soon as Shiro's security chain permits the request, with no
+"session expired" login page in between. For a page that requires login,
+the authentication filter still runs first and saves the form for replay
+after login instead. Only same-origin, `application/x-www-form-urlencoded`
+submissions are replayed, and never with client-side Faces state saving,
+where no view state is lost with the session.
+
+Set the `org.apache.shiro.form-resubmit.anonymous.disabled` context parameter
+to `true` to limit this to remembered subjects, or
+`org.apache.shiro.form-resubmit.disabled` to turn off form resubmission
+entirely.
+
=== Letting the user know, and letting the user opt out
A login page can tell the user that their form data was saved and will be
diff --git a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitRequest.java b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitRequest.java
index 88864543a..e65c25f2a 100644
--- a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitRequest.java
+++ b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitRequest.java
@@ -78,11 +78,28 @@ final class FormResubmitRequest extends HttpServletRequestWrapper {
return request instanceof ServletRequestWrapper wrapper ? unwrap(WebUtils.toHttp(wrapper.getRequest())) : request;
}
+ /**
+ * A full-page replay's response is translated for the original Ajax client by the caller,
+ * so the target must not see the Ajax header through any accessor
+ */
+ private boolean isHidden(String header) {
+ return !ajaxReplay.isPassThrough() && FACES_REQUEST_HEADER.equalsIgnoreCase(header);
+ }
+
@Override
public String getHeader(String name) {
- // A full-page replay's response is translated for the original Ajax client by the caller
- return !ajaxReplay.isPassThrough() && FACES_REQUEST_HEADER.equalsIgnoreCase(name)
- ? null : super.getHeader(name);
+ return isHidden(name) ? null : super.getHeader(name);
+ }
+
+ @Override
+ public Enumeration<String> getHeaders(String name) {
+ return isHidden(name) ? Collections.emptyEnumeration() : super.getHeaders(name);
+ }
+
+ @Override
+ public Enumeration<String> getHeaderNames() {
+ return Collections.enumeration(Collections.list(super.getHeaderNames()).stream()
+ .filter(name -> !isHidden(name)).toList());
}
@Override
diff --git a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitResponse.java b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitResponse.java
index d9ce0ec1a..bfbff58e6 100644
--- a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitResponse.java
+++ b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitResponse.java
@@ -14,9 +14,11 @@
package org.apache.shiro.ee.filters;
import static org.apache.shiro.ee.filters.FormResubmitSupport.HttpHeaderConstants.LOCATION;
+import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletResponseWrapper;
+import java.io.Closeable;
import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.lang.reflect.Method;
@@ -25,6 +27,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import lombok.Getter;
+import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.SneakyThrows;
import lombok.experimental.Delegate;
@@ -40,6 +43,7 @@ final class FormResubmitResponse extends BufferedHttpServletResponse {
private final List<Consumer<HttpServletResponse>> deferred = new ArrayList<>();
private final @Delegate(types = Captured.class) HttpServletResponse recorder;
private @Getter @Setter int status = SC_OK;
+ private ClosableOutputStream outputStream;
/**
* Header and cookie operations are recorded, to be replayed by {@link #applyTo}.
@@ -109,6 +113,26 @@ final class FormResubmitResponse extends BufferedHttpServletResponse {
MethodHandles.publicLookup().unreflect(method).bindTo(target).invokeWithArguments(args);
}
+ @Override
+ public ServletOutputStream getOutputStream() throws IOException {
+ ServletOutputStream delegate = super.getOutputStream();
+ if (outputStream == null) {
+ outputStream = new ClosableOutputStream(delegate);
+ }
+ return outputStream;
+ }
+
+ /**
+ * Container responses ignore a flush once the stream is closed, as a download writer may well do,
+ * whereas the buffering base class throws
+ */
+ @Override
+ public void flushBuffer() throws IOException {
+ if (outputStream == null || !outputStream.closed) {
+ super.flushBuffer();
+ }
+ }
+
@Override
public void resetBuffer() {
// the base class only resets its own buffer here, since the wrapped response ignores reset()
@@ -122,6 +146,21 @@ final class FormResubmitResponse extends BufferedHttpServletResponse {
deferred.clear();
}
+ /**
+ * Tracks whether the replayed component has closed the response stream
+ */
+ @RequiredArgsConstructor
+ private static final class ClosableOutputStream extends ServletOutputStream {
+ private final @Delegate(excludes = Closeable.class) ServletOutputStream delegate;
+ private boolean closed;
+
+ @Override
+ public void close() throws IOException {
+ closed = true;
+ delegate.close();
+ }
+ }
+
/**
* Shields the browser response from a replay's commit-type operations,
* that would otherwise reset or flush what the login request has already written.
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 9b6bad847..7e343f4c3 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
@@ -30,6 +30,7 @@ import static org.apache.shiro.ee.filters.FormResubmitSupportCookies.deleteCooki
import static org.apache.shiro.ee.filters.FormResubmitSupportCookies.getCookieAge;
import org.apache.shiro.crypto.CryptoException;
import org.apache.shiro.ee.filters.Forms.FallbackPredicate;
+import static org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isAnonymousFormResubmitDisabled;
import static org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isFormResubmitAjaxRenderAllDisabled;
import static org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isFormResubmitDisabled;
import java.io.IOException;
@@ -71,6 +72,7 @@ import org.apache.shiro.mgt.AbstractRememberMeManager;
import org.apache.shiro.mgt.DefaultSecurityManager;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.mgt.SessionsSecurityManager;
+import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.apache.shiro.web.util.WebUtils;
import org.jsoup.Jsoup;
@@ -99,6 +101,7 @@ public class FormResubmitSupport {
private static final String FACES_BEHAVIOR_PREFIX = "jakarta.faces.behavior.";
private static final String SEC_FETCH_SITE = "Sec-Fetch-Site";
private static final String ORIGIN = "Origin";
+ private static final String FORM_URL_ENCODED = "application/x-www-form-urlencoded";
static class HttpMethod {
static final String GET = "GET";
@@ -167,8 +170,7 @@ public class FormResubmitSupport {
AjaxReplay ajaxReplay) { }
static void savePostDataForResubmit(HttpServletRequest request, HttpServletResponse response, @NonNull String loginUrl) {
- if (isPostRequest(request) && isSecurityManagerTypeOf(getSecurityManager(),
- DefaultSecurityManager.class) && shouldSavePostData(request)) {
+ if (isSavableForm(request) && isSecurityManagerTypeOf(getSecurityManager(), DefaultSecurityManager.class)) {
String postData = getPostData(request);
var cacheKey = UUID.randomUUID();
DefaultSecurityManager dsm = getSecurityManager(DefaultSecurityManager.class);
@@ -199,6 +201,45 @@ public class FormResubmitSupport {
&& HttpMethod.POST.equalsIgnoreCase(WebUtils.toHttp(request).getMethod());
}
+ static boolean isFormUrlEncoded(HttpServletRequest request) {
+ String contentType = request.getContentType();
+ return contentType != null && contentType.trim().toLowerCase(Locale.ROOT).startsWith(FORM_URL_ENCODED);
+ }
+
+ /**
+ * Only same-origin, form-encoded POSTs can be saved and replayed: a multipart upload's body
+ * can't be reproduced from parameters, and would only bloat the cache
+ */
+ static boolean isSavableForm(HttpServletRequest request) {
+ return isPostRequest(request) && isFormUrlEncoded(request) && shouldSavePostData(request);
+ }
+
+ /**
+ * Whether a POST that arrived without a session should be replayed in place, without a login flow,
+ * once the security chain permits it: for a remembered subject, or for an anonymous subject whose
+ * session has expired, i.e. the browser presented a session id that no longer resolves to a session.
+ * Only same-origin, form-encoded submissions are replayed, and only when server-side Faces view state
+ * could have been lost with the session.
+ *
+ * @param subject the subject created for the request, without a session
+ * @param request the current request
+ * @return true if the form should be replayed
+ */
+ static boolean isDirectResubmitCandidate(@NonNull Subject subject, ServletRequest request) {
+ if (!(request instanceof HttpServletRequest httpRequest) || !isSavableForm(httpRequest)) {
+ return false;
+ }
+ var servletContext = request.getServletContext();
+ if (isFormResubmitDisabled(servletContext) || isJSFClientStateSavingMethod(servletContext)) {
+ return false;
+ }
+ if (subject.isRemembered()) {
+ return true;
+ }
+ return !subject.isAuthenticated() && !isAnonymousFormResubmitDisabled(servletContext)
+ && httpRequest.getRequestedSessionId() != null;
+ }
+
@SneakyThrows(IOException.class)
static String getPostData(ServletRequest request) {
return request.getReader().lines().collect(Collectors.joining());
diff --git a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/ShiroFilter.java b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/ShiroFilter.java
index f823574a8..c00a35da6 100644
--- a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/ShiroFilter.java
+++ b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/ShiroFilter.java
@@ -16,7 +16,8 @@ package org.apache.shiro.ee.filters;
import static org.apache.shiro.ee.cdi.ShiroScopeContext.addScopeSessionListeners;
import static org.apache.shiro.ee.filters.FormResubmitSupport.FORM_IS_RESUBMITTED;
import static org.apache.shiro.ee.filters.FormResubmitSupport.getPostData;
-import static org.apache.shiro.ee.filters.FormResubmitSupport.isJSFClientStateSavingMethod;
+import static org.apache.shiro.ee.filters.FormResubmitSupport.isDirectResubmitCandidate;
+import static org.apache.shiro.ee.filters.FormResubmitSupport.isLoginUrl;
import static org.apache.shiro.ee.filters.FormResubmitSupport.isPostRequest;
import static org.apache.shiro.ee.filters.FormResubmitSupport.normalizeSavedRequest;
import static org.apache.shiro.ee.filters.FormResubmitSupport.redirectToView;
@@ -168,9 +169,8 @@ public class ShiroFilter extends org.apache.shiro.web.servlet.ShiroFilter {
log.debug("Create Session Failed", e);
}
var newSubject = wrapped.createSubject(context);
- if (newSubject.isRemembered() && session == null
- && !isJSFClientStateSavingMethod(webContext.getServletRequest().getServletContext())) {
- log.debug("Remembered Subject with new session {}", newSubject.getPrincipal());
+ if (session == null && isDirectResubmitCandidate(newSubject, webContext.getServletRequest())) {
+ log.debug("Subject {} without a session, form will be replayed", newSubject.getPrincipal());
webContext.getServletRequest().setAttribute(FORM_IS_RESUBMITTED, Boolean.TRUE);
}
return newSubject;
@@ -238,24 +238,36 @@ public class ShiroFilter extends org.apache.shiro.web.servlet.ShiroFilter {
FilterChain origChain) throws IOException, ServletException {
if (isShiroEEDisabled(getServletContext())) {
origChain.doFilter(request, response);
- } else if (Boolean.TRUE.equals(request.getAttribute(FORM_IS_RESUBMITTED)) && isPostRequest(request)) {
- setCharacterEncodingIfNeeded(request);
- request.removeAttribute(FORM_IS_RESUBMITTED);
- String postData = getPostData(request);
- var httpRequest = WebUtils.toHttp(request);
- var httpResponse = WebUtils.toHttp(response);
- // never log the body itself: replayed forms may carry credentials or other secrets
- log.debug("Resubmitting POST to {} ({} bytes of form data)", httpRequest.getRequestURI(), postData.length());
- // the raw request URI need not be canonical, nor start with the context path
- String savedRequest = normalizeSavedRequest(Servlets.getRequestURIWithQueryString(httpRequest), httpRequest);
- if (savedRequest == null) {
- redirectToView(httpRequest, httpResponse);
- } else {
- resubmitSavedForm(postData, savedRequest, httpRequest, httpResponse, ReplayFlow.IN_PLACE);
- }
} else {
setCharacterEncodingIfNeeded(request);
- super.executeChain(request, response, origChain);
+ // Replay only once the security chain has permitted the request, so that a denied request
+ // still saves its form for the login flow, with its body intact
+ super.executeChain(request, response, (chainRequest, chainResponse) -> {
+ if (Boolean.TRUE.equals(chainRequest.getAttribute(FORM_IS_RESUBMITTED))) {
+ chainRequest.removeAttribute(FORM_IS_RESUBMITTED);
+ if (isPostRequest(chainRequest) && !isLoginUrl(WebUtils.toHttp(chainRequest))) {
+ resubmitForm(WebUtils.toHttp(chainRequest), WebUtils.toHttp(chainResponse));
+ return;
+ }
+ }
+ origChain.doFilter(chainRequest, chainResponse);
+ });
+ }
+ }
+
+ /**
+ * Replays the current POST, whose session has been lost, in place of the request's own processing
+ */
+ private static void resubmitForm(HttpServletRequest httpRequest, HttpServletResponse httpResponse) {
+ String postData = getPostData(httpRequest);
+ // never log the body itself: anonymous replays may carry credentials or other secrets
+ log.debug("Resubmitting POST to {} ({} bytes of form data)", httpRequest.getRequestURI(), postData.length());
+ // the raw request URI need not be canonical, nor start with the context path
+ String savedRequest = normalizeSavedRequest(Servlets.getRequestURIWithQueryString(httpRequest), httpRequest);
+ if (savedRequest == null) {
+ redirectToView(httpRequest, httpResponse);
+ } else {
+ resubmitSavedForm(postData, savedRequest, httpRequest, httpResponse, ReplayFlow.IN_PLACE);
}
}
diff --git a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/listeners/EnvironmentLoaderListener.java b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/listeners/EnvironmentLoaderListener.java
index 6e121c68e..1296cd3f5 100644
--- a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/listeners/EnvironmentLoaderListener.java
+++ b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/listeners/EnvironmentLoaderListener.java
@@ -45,6 +45,7 @@ public class EnvironmentLoaderListener extends EnvironmentLoader implements Serv
private static final String SHIRO_EE_DISABLE_CHAR_ENCODING_PARAM = "org.apache.shiro.ee.disable-character-encoding";
private static final String SHIRO_EE_CHAR_ENCODING_PARAM = "org.apache.shiro.ee.character-encoding";
private static final String FORM_RESUBMIT_DISABLED_PARAM = "org.apache.shiro.form-resubmit.disabled";
+ private static final String FORM_RESUBMIT_ANONYMOUS_DISABLED_PARAM = "org.apache.shiro.form-resubmit.anonymous.disabled";
private static final String FORM_RESUBMIT_SECURE_COOKIES = "org.apache.shiro.form-resubmit.secure-cookies";
private static final String FORM_RESUBMIT_AJAX_RENDER_ALL_DISABLED_PARAM =
"org.apache.shiro.form-resubmit.ajax-render-all.disabled";
@@ -62,6 +63,15 @@ public class EnvironmentLoaderListener extends EnvironmentLoader implements Serv
return Boolean.TRUE.equals(ctx.getAttribute(FORM_RESUBMIT_DISABLED_PARAM));
}
+ /**
+ * @param ctx servlet context
+ * @return whether replaying an expired-session form for a subject that is neither authenticated
+ * nor remembered, i.e. without a login flow, is disabled
+ */
+ public static boolean isAnonymousFormResubmitDisabled(ServletContext ctx) {
+ return Boolean.TRUE.equals(ctx.getAttribute(FORM_RESUBMIT_ANONYMOUS_DISABLED_PARAM));
+ }
+
public static boolean isFormResubmitSecureCookies(ServletContext ctx) {
return Boolean.TRUE.equals(ctx.getAttribute(FORM_RESUBMIT_SECURE_COOKIES));
}
@@ -94,6 +104,7 @@ public class EnvironmentLoaderListener extends EnvironmentLoader implements Serv
copyBooleanInitParameter(sce.getServletContext(), SHIRO_EE_DISABLED_PARAM);
copyBooleanInitParameter(sce.getServletContext(), SHIRO_EE_REDIRECT_DISABLED_PARAM);
copyBooleanInitParameter(sce.getServletContext(), FORM_RESUBMIT_DISABLED_PARAM);
+ copyBooleanInitParameter(sce.getServletContext(), FORM_RESUBMIT_ANONYMOUS_DISABLED_PARAM);
String secureCookiesStr = sce.getServletContext().getInitParameter(FORM_RESUBMIT_SECURE_COOKIES);
if (Optional.ofNullable(System.getProperty(FORM_RESUBMIT_SECURE_COOKIES)).map(Boolean::valueOf)
.or(() -> Optional.ofNullable(secureCookiesStr).map(Boolean::valueOf)).orElse(true)) {
diff --git a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/DirectResubmitCandidateTest.java b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/DirectResubmitCandidateTest.java
new file mode 100644
index 000000000..5ecf30552
--- /dev/null
+++ b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/DirectResubmitCandidateTest.java
@@ -0,0 +1,148 @@
+/*
+ * Licensed 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 static jakarta.faces.application.StateManager.STATE_SAVING_METHOD_CLIENT;
+import static jakarta.faces.application.StateManager.STATE_SAVING_METHOD_PARAM_NAME;
+import static org.apache.shiro.ee.filters.FormResubmitSupport.isDirectResubmitCandidate;
+import static org.apache.shiro.ee.filters.FormResubmitSupport.isFormUrlEncoded;
+import static org.apache.shiro.ee.filters.FormResubmitSupport.isSavableForm;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.http.HttpServletRequest;
+import org.apache.shiro.subject.Subject;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+/**
+ * Replaying a form in place, without a login flow, for remembered and anonymous subjects
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class DirectResubmitCandidateTest {
+ private static final String FORM_RESUBMIT_DISABLED_PARAM = "org.apache.shiro.form-resubmit.disabled";
+ private static final String FORM_RESUBMIT_ANONYMOUS_DISABLED_PARAM = "org.apache.shiro.form-resubmit.anonymous.disabled";
+
+ @Mock
+ private HttpServletRequest request;
+ @Mock
+ private ServletContext servletContext;
+ @Mock
+ private Subject subject;
+
+ @BeforeEach
+ void anonymousSameOriginFormPostWithExpiredSession() {
+ when(request.getServletContext()).thenReturn(servletContext);
+ when(request.getMethod()).thenReturn("POST");
+ when(request.getContentType()).thenReturn("application/x-www-form-urlencoded; charset=UTF-8");
+ when(request.getHeader("Sec-Fetch-Site")).thenReturn("same-origin");
+ when(request.getRequestedSessionId()).thenReturn("expired-session-id");
+ when(subject.isAuthenticated()).thenReturn(false);
+ when(subject.isRemembered()).thenReturn(false);
+ }
+
+ @Test
+ void anonymousExpiredSessionIsReplayed() {
+ assertThat(isDirectResubmitCandidate(subject, request)).isTrue();
+ }
+
+ @Test
+ void anonymousWithoutPriorSessionIsNotReplayed() {
+ when(request.getRequestedSessionId()).thenReturn(null);
+ assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+ }
+
+ @Test
+ void rememberedIsReplayedEvenWithoutPriorSession() {
+ when(request.getRequestedSessionId()).thenReturn(null);
+ when(subject.isRemembered()).thenReturn(true);
+ assertThat(isDirectResubmitCandidate(subject, request)).isTrue();
+ }
+
+ @Test
+ void anonymousReplayCanBeDisabled() {
+ when(servletContext.getAttribute(FORM_RESUBMIT_ANONYMOUS_DISABLED_PARAM)).thenReturn(Boolean.TRUE);
+ assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+ }
+
+ @Test
+ void anonymousDisablingKeepsRememberedReplay() {
+ when(servletContext.getAttribute(FORM_RESUBMIT_ANONYMOUS_DISABLED_PARAM)).thenReturn(Boolean.TRUE);
+ when(subject.isRemembered()).thenReturn(true);
+ assertThat(isDirectResubmitCandidate(subject, request)).isTrue();
+ }
+
+ @Test
+ void globalDisablingStopsAllReplays() {
+ when(servletContext.getAttribute(FORM_RESUBMIT_DISABLED_PARAM)).thenReturn(Boolean.TRUE);
+ assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+ when(subject.isRemembered()).thenReturn(true);
+ assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+ }
+
+ @Test
+ void clientStateSavingNeedsNoReplay() {
+ when(servletContext.getInitParameter(STATE_SAVING_METHOD_PARAM_NAME)).thenReturn(STATE_SAVING_METHOD_CLIENT);
+ assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+ }
+
+ @Test
+ void getIsNotReplayed() {
+ when(request.getMethod()).thenReturn("GET");
+ assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+ }
+
+ @Test
+ void crossSitePostIsNotReplayed() {
+ when(request.getHeader("Sec-Fetch-Site")).thenReturn("cross-site");
+ assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+ }
+
+ @Test
+ void nonFormContentIsNotReplayed() {
+ when(request.getContentType()).thenReturn("application/json");
+ assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+ when(request.getContentType()).thenReturn("multipart/form-data; boundary=xyz");
+ assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+ }
+
+ @Test
+ void formContentTypeDetection() {
+ when(request.getContentType()).thenReturn(null);
+ assertThat(isFormUrlEncoded(request)).isFalse();
+ when(request.getContentType()).thenReturn("Application/X-WWW-Form-UrlEncoded");
+ assertThat(isFormUrlEncoded(request)).isTrue();
+ when(request.getContentType()).thenReturn(" application/x-www-form-urlencoded ;charset=ISO-8859-1");
+ assertThat(isFormUrlEncoded(request)).isTrue();
+ }
+
+ @Test
+ void multipartUploadIsNeverSavedForLoginFlow() {
+ assertThat(isSavableForm(request)).isTrue();
+ when(request.getContentType()).thenReturn("multipart/form-data; boundary=xyz");
+ assertThat(isSavableForm(request)).isFalse();
+ when(request.getContentType()).thenReturn("application/x-www-form-urlencoded");
+ when(request.getMethod()).thenReturn("GET");
+ assertThat(isSavableForm(request)).isFalse();
+ when(request.getMethod()).thenReturn("POST");
+ when(request.getHeader("Sec-Fetch-Site")).thenReturn("cross-site");
+ assertThat(isSavableForm(request)).isFalse();
+ }
+}
diff --git a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitRequestTest.java b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitRequestTest.java
new file mode 100644
index 000000000..5238bbefb
--- /dev/null
+++ b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitRequestTest.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed 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 java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import jakarta.servlet.http.HttpServletRequest;
+import org.apache.shiro.ee.filters.FormResubmitSupport.AjaxReplay;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+
+import static org.mockito.Mockito.lenient;
+
+import org.mockito.junit.jupiter.MockitoExtension;
+
+/**
+ * A full-page replay hides the Faces Ajax header from the target through every accessor
+ */
+@ExtendWith(MockitoExtension.class)
+class FormResubmitRequestTest {
+ @Mock
+ private HttpServletRequest login;
+
+ @BeforeEach
+ void setUp() {
+ lenient().when(login.getAttributeNames()).thenReturn(Collections.emptyEnumeration());
+ lenient().when(login.getHeaderNames())
+ .thenReturn(Collections.enumeration(List.of("Faces-Request", "Host")));
+ lenient().when(login.getHeader("Faces-Request")).thenReturn("partial/ajax");
+ lenient().when(login.getHeaders("Faces-Request"))
+ .thenReturn(Collections.enumeration(List.of("partial/ajax")));
+ }
+
+ private FormResubmitRequest request(AjaxReplay ajaxReplay) {
+ return new FormResubmitRequest(login, "POST", Map.of(), ajaxReplay);
+ }
+
+ @Test
+ void facesRequestHeaderIsHiddenFromAllAccessorsOnFullPageReplay() {
+ var request = request(AjaxReplay.FULL_PAGE);
+ assertThat(request.getHeader("Faces-Request")).isNull();
+ assertThat(request.getHeader("faces-request")).isNull();
+ assertThat(Collections.list(request.getHeaders("Faces-Request"))).isEmpty();
+ assertThat(Collections.list(request.getHeaderNames())).containsExactly("Host");
+ }
+
+ @Test
+ void facesRequestHeaderIsVisibleOnPassThroughReplay() {
+ var request = request(AjaxReplay.PASS_THROUGH);
+ assertThat(request.getHeader("Faces-Request")).isEqualTo("partial/ajax");
+ assertThat(Collections.list(request.getHeaders("Faces-Request"))).containsExactly("partial/ajax");
+ assertThat(Collections.list(request.getHeaderNames())).containsExactly("Faces-Request", "Host");
+ }
+}
diff --git a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitResponseTest.java b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitResponseTest.java
index 25b7945e3..d73114639 100644
--- a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitResponseTest.java
+++ b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitResponseTest.java
@@ -115,6 +115,20 @@ class FormResubmitResponseTest {
verify(browser, never()).sendError(anyInt(), anyString());
}
+ @Test
+ void flushAfterStreamClosedIsIgnored() throws IOException {
+ when(browser.getCharacterEncoding()).thenReturn("UTF-8");
+ when(browser.getBufferSize()).thenReturn((int) Short.MAX_VALUE);
+ var stream = response.getOutputStream();
+ assertThat(response.getOutputStream()).isSameAs(stream);
+ stream.write("body".getBytes());
+ // e.g. a download writer closes the stream, then PrimeFaces flushes the response
+ stream.close();
+ response.flushBuffer();
+ assertThat(response.getBufferAsString()).isEqualTo("body");
+ verify(browser, never()).flushBuffer();
+ }
+
@Test
void resetDiscardsEverythingCaptured() throws IOException {
when(browser.getCharacterEncoding()).thenReturn("UTF-8");