Skip to content
Draft
7 changes: 7 additions & 0 deletions integration-tests/jakarta-ee/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,13 @@
<artifactId>payara-micro</artifactId>
<version>${payara.version}</version>
</dependency>
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-bom</artifactId>
<version>4.49.0</version>
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,13 +222,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 +245,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
2 changes: 1 addition & 1 deletion integration-tests/meecrowave-support/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jcl-over-slf4j</artifactId>
<version>2.0.20</version>
<version>${slf4j.version}</version>
<scope>runtime</scope>
</dependency>

Expand Down
57 changes: 20 additions & 37 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,11 @@
</distributionManagement>

<properties>
<!-- *** Remove the line below when 3.0.0 is released -->
<shiro.previousVersion>3.0.0</shiro.previousVersion>
<!-- Replaced by the build number plugin at build time: -->
<buildNumber>${user.name}-${maven.build.timestamp}</buildNumber>
<project.build.outputTimestamp>2026-02-07T22:56:07Z</project.build.outputTimestamp>
<root.dir>${maven.multiModuleProjectDirectory}</root.dir>
<rat.skip>false</rat.skip>
<jacoco.skip>true</jacoco.skip>
<japicmp-skip>false</japicmp-skip>
<japicmp.skip>${japicmp-skip}</japicmp.skip>
Expand Down Expand Up @@ -330,16 +329,18 @@
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-site-plugin</artifactId>
<version>4.0.0-M16</version>
</plugin>
<plugin>
<groupId>org.apache.rat</groupId>
<artifactId>apache-rat-plugin</artifactId>
<configuration>
<!-- note that this configuration needs to be maintained both in pluginManagement and reporting sections -->
<!-- also applied to the RAT report in <reporting> -->
<!--
RAT is not thread-safe in parallel (-T / mvnd) builds (shared .gitignore parser and
license matchers), so it is skipped in modules and runs once at the root,
scanning the whole tree. This also covers command-line "apache-rat:check" runs.
-->
<skip>true</skip>
<excludeSubProjects>false</excludeSubProjects>
<inputExcludes>
<inputExclude>**/.externalToolBuilders/*</inputExclude>
<inputExclude>**/infinitest.filters</inputExclude>
Expand Down Expand Up @@ -440,15 +441,18 @@
<artifactId>japicmp-maven-plugin</artifactId>
<version>0.26.2</version>
<configuration>
<!-- If specific old version is needed, uncomment the following and set the version to compare against
<oldVersion>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>${project.artifactId}</artifactId>
<version>${shiro.previousVersion}</version>
<version>3.0.0</version>
<type>jar</type>
</dependency>
</oldVersion>
-->
<parameter>
<oldVersionPattern>\d+\.0\.0</oldVersionPattern>
<onlyModified>true</onlyModified>
<breakBuildOnSourceIncompatibleModifications>true</breakBuildOnSourceIncompatibleModifications>
<breakBuildOnBinaryIncompatibleModifications>true</breakBuildOnBinaryIncompatibleModifications>
Expand Down Expand Up @@ -538,6 +542,11 @@
<plugin>
<groupId>org.apache.rat</groupId>
<artifactId>apache-rat-plugin</artifactId>
<!-- only run at the root, see pluginManagement -->
<inherited>false</inherited>
<configuration>
<skip>${rat.skip}</skip>
</configuration>
<executions>
<execution>
<id>rat-check</id>
Expand Down Expand Up @@ -1412,36 +1421,10 @@
<plugin>
<groupId>org.apache.rat</groupId>
<artifactId>apache-rat-plugin</artifactId>
<!-- only run at the root -->
<inherited>false</inherited>
<!-- configuration is inherited from build/pluginManagement -->
<configuration>
<!-- note that this configuration needs to be maintained both in pluginManagement and reporting sections -->
<inputExcludes>
<inputExclude>**/.externalToolBuilders/*</inputExclude>
<inputExclude>**/infinitest.filters</inputExclude>
<!-- Apparently some test in samples/spring-client generates velocity log - would better to reconfigure to output to target/ -->
<inputExclude>velocity.log</inputExclude>
<inputExclude>CONTRIBUTING.md</inputExclude>
<inputExclude>AGENTS.md</inputExclude>
<inputExclude>SECURITY.md</inputExclude>
<inputExclude>**/README.md</inputExclude>
<inputExclude>**/*.json</inputExclude>
<inputExclude>**/spring.factories</inputExclude>
<inputExclude>**/org.springframework.boot.autoconfigure.AutoConfiguration.imports</inputExclude>
<inputExclude>**/spring.provides</inputExclude>
<inputExclude>**/*.iml</inputExclude>
<inputExclude>**/*.idea/**</inputExclude>
<inputExclude>**/target/**</inputExclude>
<inputExclude>**/nb-configuration.xml</inputExclude>
<inputExclude>**/faces-config.NavData</inputExclude>
<inputExclude>**/.project</inputExclude>
<inputExclude>**/.classpath</inputExclude>
<inputExclude>**/.settings/*</inputExclude>
<inputExclude>.github/linters/codespell.txt</inputExclude>
<inputExclude>**/org.mockito.plugins.MockMaker</inputExclude>
<inputExclude>.mvn/*</inputExclude>
<inputExclude>.jenkins_maven_args</inputExclude>
</inputExcludes>
<skip>${rat.skip}</skip>
</configuration>
</plugin>
<plugin>
Expand Down
43 changes: 43 additions & 0 deletions support/jakarta-ee/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<!--
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. Remembered Ajax submissions retain the two-POST flow, buffering
intermediate responses. A calling Faces context is restored after each dispatch.
The successful POST's cookies are preserved unchanged; the expired-view probe
must not replace its flash cookie and lose submitted-form messages.

## 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.

The old `org.apache.shiro.form-resubmit-host`,
`org.apache.shiro.form-resubmit-port`, and form-resubmit blacklist settings are
no longer used. Saved-form cookies still use the existing secure-cookie setting;
there is no separate replay cookie jar or cookie-header rewriting.
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
* 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.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
* 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 final String method;
private final Map<String, String[]> parameters = new LinkedHashMap<>();
private final Map<String, Object> attributes = new HashMap<>();

FormResubmitRequest(HttpServletRequest request, String method, String formData) {
super((HttpServletRequest) unwrap(request));
this.method = method;
Map<String, List<String>> parsed = new LinkedHashMap<>();
for (String field : formData.split("&")) {
if (!field.isEmpty()) {
String[] pair = field.split("=", 2);
parsed.computeIfAbsent(decode(pair[0]), name -> new ArrayList<>()).add(pair.length == 2 ? decode(pair[1]) : "");
}
}
parsed.forEach((name, values) -> parameters.put(name, values.toArray(String[]::new)));
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) {
return request instanceof ServletRequestWrapper wrapper && wrapper.isWrapperFor(FormResubmitRequest.class);
}

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

private static String decode(String value) {
return URLDecoder.decode(value, StandardCharsets.UTF_8);
}

@Override
public String getMethod() {
return method;
}

@Override
public String getHeader(String name) {
// Replays execute full-page actions. The caller translates their response for the original Ajax client.
return "Faces-Request".equalsIgnoreCase(name) ? null : super.getHeader(name);
}

@Override
public Map<String, String[]> getParameterMap() {
return Collections.unmodifiableMap(parameters);
}

@Override
public String getParameter(String name) {
String[] values = parameters.get(name);
return values == null ? null : values[0];
}

@Override
public String[] getParameterValues(String name) {
return parameters.get(name);
}

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

@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) {
attributes.remove(name);
} else {
attributes.put(name, value);
}
}

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