Skip to content
Open
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
72c9564
enh: rework form resubmit via self-dispatch rather than a network-call
lprimak Sep 29, 2026
c1f8949
chore: moved meecrowave slf4j into a variable
lprimak Sep 29, 2026
eaffb39
chore: removed extra blank line
lprimak Sep 29, 2026
8497768
Merge branch 'main' into remove-resubmit-netcall
lprimak Sep 29, 2026
ce5ebc5
revert integration-tests/jakarta-ee/src/main/java/org/apache/shiro/te…
lprimak Sep 30, 2026
3c02bc1
removed AI slop test
lprimak Sep 30, 2026
fc86f59
removed extra added newline
lprimak Sep 30, 2026
4558517
simplificatino round 1
lprimak Sep 30, 2026
a583015
removed some methods
lprimak Sep 30, 2026
e444097
simplify
lprimak Sep 30, 2026
6705073
more simplification
lprimak Oct 1, 2026
0f2dd4a
added selenium BOM
lprimak Oct 1, 2026
78782a3
RAT check: run only at root of the tree
lprimak Oct 1, 2026
0fad87f
Merge branch 'main' into remove-resubmit-netcall
lprimak Oct 3, 2026
aa3906f
further code simplification
lprimak Oct 3, 2026
ed0ec6e
Merge branch 'main' into remove-resubmit-netcall
lprimak Oct 7, 2026
9e6b326
cleanup
lprimak Oct 7, 2026
cd0bdef
FormResubmitRequest.java cleanup
lprimak Oct 8, 2026
7868627
Cleanup of ShiroFilter.java
lprimak Oct 8, 2026
eee4a59
more cleanup
lprimak Oct 8, 2026
54d4555
more cleanup
lprimak Oct 8, 2026
625f317
spelling
lprimak Oct 8, 2026
d9bb20f
more cleanup
lprimak Oct 8, 2026
88a05e7
removal of more dead code and more code reuse
lprimak Oct 8, 2026
e8c46ae
more simplification
lprimak Oct 8, 2026
98238bb
cleanup
lprimak Oct 8, 2026
cf2d8e5
more simplification
lprimak Oct 8, 2026
b276725
more cleanup
lprimak Oct 8, 2026
af12a12
clean up
lprimak Oct 8, 2026
999264c
chore: consolidated Location / Set-Cookie constants
lprimak Oct 8, 2026
e04a262
reverted set-cookie - only used once
lprimak Oct 8, 2026
89956b2
enh: keep in-place Faces Ajax form replay as an Ajax request
lprimak Oct 8, 2026
55d546b
don't log full form data
lprimak Oct 8, 2026
e6f23ac
Using __Host prefix for resubmit cookie
lprimak Oct 9, 2026
4556711
form data discard API
lprimak Oct 9, 2026
07e3cc0
README
lprimak Oct 9, 2026
41a2601
fixed test
lprimak Oct 9, 2026
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 @@ -18,7 +18,6 @@

import static com.flowlogix.util.ShrinkWrapManipulator.getContextParamValue;
import static org.apache.shiro.testing.jakarta.ee.Deployments.standardActions;
import static org.apache.shiro.testing.jakarta.ee.Deployments.isClientStateSavingIntegrationTest;
import static org.apache.shiro.testing.jakarta.ee.Deployments.isShiroNativeSessionsIntegrationTest;

import java.net.URL;
Expand Down Expand Up @@ -222,13 +221,17 @@ void incorrectLoginOnce() {
@Test
@OperateOnDeployment(DEPLOYMENT_DEV_MODE)
void nonAjaxSessionExpired() {
nonAjaxSessionExpired("Jack", "Frost");
}

private void nonAjaxSessionExpired(String first, String last) {
webDriver.get(baseURL + "shiro/form");
login();
invalidateSession.click();
waitGui(webDriver).until(ExpectedConditions.alertIsPresent());
webDriver.switchTo().alert().accept();
firstName.sendKeys("Jack");
lastName.sendKeys("Frost");
firstName.sendKeys(first);
lastName.sendKeys(last);
guardHttp(submitFirst).click();
assertThat(sessionExpiredMessage.getText()).isEqualTo("Your Session Has Expired");
}
Expand All @@ -241,6 +244,14 @@ void nonAjaxResubmit() {
assertThat(messages.getText()).isEqualTo("Form Submitted - firstName: Jack, lastName: Frost");
}

@Test
@OperateOnDeployment(DEPLOYMENT_DEV_MODE)
void nonAjaxResubmitPreservesEscapedInput() {
nonAjaxSessionExpired("Jörg & Sons + =", "Frost 雪");
login();
assertThat(messages.getText()).isEqualTo("Form Submitted - firstName: Jörg & Sons + =, lastName: Frost 雪");
}

@Test
@OperateOnDeployment(DEPLOYMENT_DEV_MODE)
void nonAjaxResubmitAfterFailedLogin() {
Expand Down Expand Up @@ -304,26 +315,18 @@ void ajaxRememberedResubmit() {
invalidateSession.click();
waitGui(webDriver).until(ExpectedConditions.alertIsPresent());
webDriver.switchTo().alert().accept();
if (isClientStateSavingIntegrationTest()) {
guardAjax(submitSecond).click();
address.clear();
city.clear();
} else {
waitForHttp(submitSecond).click();
}
guardAjax(submitSecond).click();
address.clear();
city.clear();
assertThat(secondFormMessages.getText()).isEqualTo("2nd Form Submitted - Address: 1 Houston Street, City: New York");
address.sendKeys("Workshop");
city.sendKeys("North Pole");
invalidateSession.click();
waitGui(webDriver).until(ExpectedConditions.alertIsPresent());
webDriver.switchTo().alert().accept();
if (isClientStateSavingIntegrationTest()) {
guardAjax(submitSecond).click();
address.clear();
city.clear();
} else {
waitForHttp(submitSecond).click();
}
guardAjax(submitSecond).click();
address.clear();
city.clear();
assertThat(secondFormMessages.getText()).isEqualTo("2nd Form Submitted - Address: Workshop, City: North Pole");
address.sendKeys("LAX Airport");
city.sendKeys("Los Angeles");
Expand Down
83 changes: 83 additions & 0 deletions support/jakarta-ee/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<!--
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.
-->

# Jakarta EE form resubmission

Saved forms are replayed within the current web application using
`RequestDispatcher.forward`, without an outbound HTTP connection. The replay
uses the current Shiro subject, session, and browser response. Its request body
and form parameters replace those of the login request.

For server-side Faces state saving, a buffered GET obtains a new view state
before the POST. A calling Faces context is restored after each dispatch.

Each replay's status, headers, cookies and body are captured rather than written
to the browser response, which is also shielded from `reset()`, `resetBuffer()`
and `flushBuffer()`. Only a successful replay is applied, with its headers and
cookies. View-state GETs and failed attempts leave the login request's response,
such as its session cookies, untouched. 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:
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
renders the whole view (`@all`) by default, which resynchronizes the page and
the view state of all its forms with the server. Set the
`org.apache.shiro.form-resubmit.ajax-render-all.disabled` context parameter to
`true` to keep the form's own render targets instead; components outside them
then keep their pre-expiry client state, and with Mojarra, other forms on the
page keep their expired view state. A partial response that reports an
unhandled Faces error is treated as a failed replay and falls back to a redirect
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.

## Application filter configuration

Shiro's Jakarta EE filter is mapped to `DispatcherType.FORWARD`, so the forwarded
target's security chain runs again. Application filters needed during replay
must also be mapped to `FORWARD`, not only `REQUEST`. Leave Shiro's
`filterOncePerRequest` disabled when using form resubmission.

Replay remains in the same servlet request lifecycle. Application filters and
request-scoped components should not assume that a replay starts a new external
request. Saved targets must be within the current context; servlet-private
`WEB-INF` and `META-INF` resources cannot be replay targets.

Resubmission is best-effort. Replays are buffered, so if the forward fails or
the target doesn't answer with `200` or `302`, the fault is logged and the user
is simply redirected to the saved request without the form being resubmitted.

The old `org.apache.shiro.form-resubmit-host`,
`org.apache.shiro.form-resubmit-port`, and form-resubmit blacklist settings are
no longer used. There is no separate replay cookie jar or cookie-header rewriting.

## Saved-form cookie

The cookie that keys a saved form is `HttpOnly`, and with the default
`org.apache.shiro.form-resubmit.secure-cookies` setting it is also `Secure`
and carries the `__Host-` prefix, so a browser only accepts it from this very
host over HTTPS: another subdomain, or a plain-HTTP connection, cannot plant a
saved form into a user's browser to have it replayed under that user's login.
The prefix requires `Path=/`, so the cookie is named
`__Host-org.apache.shiro.form-data-key` followed by the URL-encoded context
path (e.g. `%2Fmy-app`), which keeps co-hosted applications apart. With secure
cookies disabled, the cookie keeps its plain name and context path.

A form saved by a previous version is not replayed after upgrading, since its
cookie has the old name; the user is simply redirected to the saved request.
There is deliberately no fallback to the old name, which would reopen the
planting vector.
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* 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 jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletRequestWrapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.Getter;
import lombok.experimental.Delegate;
import org.apache.shiro.ee.filters.FormResubmitSupport.AjaxReplay;
import org.apache.shiro.web.util.WebUtils;
import org.omnifaces.filter.MutableRequestFilter.MutableRequest;

/**
* Replays saved form data in place of the login request's parameters, with a private request scope
* (attributes), so that Faces and CDI request state doesn't leak between replays and the login request.
* Wraps the container's own request, so that the forward supplies the target's paths
* beneath application wrappers (e.g. OmniFaces FacesViews) that would otherwise mask them.
*/
final class FormResubmitRequest extends HttpServletRequestWrapper {
private static final List<String> DISPATCH_SCOPED_PREFIXES = List.of("jakarta.faces.", "com.sun.faces.",
"org.apache.myfaces.", "org.omnifaces.", "jakarta.servlet.forward.", "jakarta.servlet.include.",
FormResubmitSupport.FORM_IS_RESUBMITTED);
private static final String FACES_REQUEST_HEADER = "Faces-Request";
private final @Getter String method;
private final AjaxReplay ajaxReplay;
private final @Delegate(types = Parameters.class) MutableRequest parameters;
private final Map<String, Object> attributes = new HashMap<>();

@SuppressWarnings("unused")
private interface Parameters {
String getParameter(String name);
String[] getParameterValues(String name);
Enumeration<String> getParameterNames();
Map<String, String[]> getParameterMap();
}

FormResubmitRequest(HttpServletRequest request, String method, Map<String, List<String>> formFields,
AjaxReplay ajaxReplay) {
super(unwrap(request));
this.method = method;
this.ajaxReplay = ajaxReplay;
parameters = new MutableRequest(request) {
@Override
public Map<String, List<String>> getMutableParameterMap() {
return formFields;
}
};
Collections.list(request.getAttributeNames()).stream()
.filter(name -> DISPATCH_SCOPED_PREFIXES.stream().noneMatch(name::startsWith))
.forEach(name -> attributes.put(name, request.getAttribute(name)));
}

static boolean isResubmit(ServletRequest request) {
// isWrapperFor() inspects only the wrapped chain, not the wrapper itself
return request instanceof FormResubmitRequest
|| request instanceof ServletRequestWrapper wrapper && wrapper.isWrapperFor(FormResubmitRequest.class);
}

private static HttpServletRequest unwrap(HttpServletRequest request) {
return request instanceof ServletRequestWrapper wrapper ? unwrap(WebUtils.toHttp(wrapper.getRequest())) : request;
}

@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);
}

@Override
public Object getAttribute(String name) {
return attributes.get(name);
}

@Override
public Enumeration<String> getAttributeNames() {
return Collections.enumeration(attributes.keySet());
}

@Override
public void setAttribute(String name, Object value) {
if (value == null) {
removeAttribute(name);
} else {
attributes.put(name, value);
}
}

@Override
public void removeAttribute(String name) {
attributes.remove(name);
}
}
Loading
Loading