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");