diff --git a/integration-tests/jakarta-ee/pom.xml b/integration-tests/jakarta-ee/pom.xml index b8924512b6..fc9118d99b 100644 --- a/integration-tests/jakarta-ee/pom.xml +++ b/integration-tests/jakarta-ee/pom.xml @@ -147,6 +147,13 @@ payara-micro ${payara.version} + + org.seleniumhq.selenium + selenium-bom + 4.49.0 + import + pom + 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 a8e9c63cbd..437a0b4646 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 @@ -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"); } @@ -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() { diff --git a/integration-tests/meecrowave-support/pom.xml b/integration-tests/meecrowave-support/pom.xml index c367a262a0..a3068901c0 100644 --- a/integration-tests/meecrowave-support/pom.xml +++ b/integration-tests/meecrowave-support/pom.xml @@ -61,7 +61,7 @@ org.slf4j jcl-over-slf4j - 2.0.20 + ${slf4j.version} runtime diff --git a/pom.xml b/pom.xml index ca6453c6d9..4afe47ad0f 100644 --- a/pom.xml +++ b/pom.xml @@ -66,12 +66,11 @@ - - 3.0.0 ${user.name}-${maven.build.timestamp} 2026-02-07T22:56:07Z ${maven.multiModuleProjectDirectory} + false true false ${japicmp-skip} @@ -330,16 +329,18 @@ - - org.apache.maven.plugins - maven-site-plugin - 4.0.0-M16 - org.apache.rat apache-rat-plugin - + + + true + false **/.externalToolBuilders/* **/infinitest.filters @@ -440,15 +441,18 @@ japicmp-maven-plugin 0.26.2 + + \d+\.0\.0 true true true @@ -538,6 +542,11 @@ org.apache.rat apache-rat-plugin + + false + + ${rat.skip} + rat-check @@ -1412,36 +1421,10 @@ org.apache.rat apache-rat-plugin - false + - - - **/.externalToolBuilders/* - **/infinitest.filters - - velocity.log - CONTRIBUTING.md - AGENTS.md - SECURITY.md - **/README.md - **/*.json - **/spring.factories - **/org.springframework.boot.autoconfigure.AutoConfiguration.imports - **/spring.provides - **/*.iml - **/*.idea/** - **/target/** - **/nb-configuration.xml - **/faces-config.NavData - **/.project - **/.classpath - **/.settings/* - .github/linters/codespell.txt - **/org.mockito.plugins.MockMaker - .mvn/* - .jenkins_maven_args - + ${rat.skip} diff --git a/support/jakarta-ee/README.md b/support/jakarta-ee/README.md new file mode 100644 index 0000000000..4253dca684 --- /dev/null +++ b/support/jakarta-ee/README.md @@ -0,0 +1,43 @@ + + +# 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. 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 new file mode 100644 index 0000000000..51c250f5ff --- /dev/null +++ b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitRequest.java @@ -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 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 parameters = new LinkedHashMap<>(); + private final Map attributes = new HashMap<>(); + + FormResubmitRequest(HttpServletRequest request, String method, String formData) { + super((HttpServletRequest) unwrap(request)); + this.method = method; + Map> 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 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 getParameterNames() { + return Collections.enumeration(parameters.keySet()); + } + + @Override + public Object getAttribute(String name) { + return attributes.get(name); + } + + @Override + public Enumeration 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); + } +} 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 new file mode 100644 index 0000000000..e55531b40c --- /dev/null +++ b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitResponse.java @@ -0,0 +1,77 @@ +/* + * 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.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import org.omnifaces.servlet.BufferedHttpServletResponse; + +/** + * Buffers a replay's body and captures its status instead of committing them to the browser. + * Other headers and cookies pass through, unless cookies are being discarded. + */ +final class FormResubmitResponse extends BufferedHttpServletResponse { + private final boolean keepCookies; + private int status = SC_OK; + + FormResubmitResponse(HttpServletResponse response, boolean keepCookies) { + super(response); + this.keepCookies = keepCookies; + } + + @Override + public int getStatus() { + return status; + } + + @Override + public void setStatus(int status) { + this.status = status; + } + + @Override + public void sendError(int status) { + setStatus(status); + } + + @Override + public void sendError(int status, String message) { + setStatus(status); + } + + @Override + public void sendRedirect(String location) { + setStatus(SC_FOUND); + setHeader("Location", location); + } + + @Override + public void addCookie(Cookie cookie) { + if (keepCookies) { + super.addCookie(cookie); + } + } + + @Override + public void flushBuffer() { + } + + @Override + public void setContentLength(int len) { + } + + @Override + public void setContentLengthLong(long len) { + } +} 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 775e8f5106..4e9fb8b472 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 @@ -20,44 +20,25 @@ import static org.apache.shiro.SecurityUtils.unwrapSecurityManager; import static org.apache.shiro.ee.filters.FormAuthenticationFilter.LOGIN_URL_ATTR_NAME; import static org.apache.shiro.ee.filters.FormResubmitSupport.HttpHeaderConstants.CONTENT_TYPE; -import static org.apache.shiro.ee.filters.FormResubmitSupport.HttpHeaderConstants.COOKIE; import static org.apache.shiro.ee.filters.FormResubmitSupport.HttpHeaderConstants.LOCATION; -import static org.apache.shiro.ee.filters.FormResubmitSupport.HttpHeaderConstants.SET_COOKIE; -import static org.apache.shiro.ee.filters.FormResubmitSupport.HttpResponseCodes.AUTHFAIL; import static org.apache.shiro.ee.filters.FormResubmitSupport.HttpResponseCodes.FOUND; import static org.apache.shiro.ee.filters.FormResubmitSupport.HttpResponseCodes.OK; -import static org.apache.shiro.ee.filters.FormResubmitSupport.MediaType.APPLICATION_FORM_URLENCODED; import static org.apache.shiro.ee.filters.FormResubmitSupport.MediaType.TEXT_XML; -import static org.apache.shiro.ee.filters.FormResubmitSupportCookies.DONT_ADD_ANY_MORE_COOKIES; import static org.apache.shiro.ee.filters.FormResubmitSupportCookies.addCookie; -import static org.apache.shiro.ee.filters.FormResubmitSupportCookies.cookieStreamFromHeader; import static org.apache.shiro.ee.filters.FormResubmitSupportCookies.deleteCookie; import static org.apache.shiro.ee.filters.FormResubmitSupportCookies.getCookieAge; -import static org.apache.shiro.ee.filters.FormResubmitSupportCookies.getSessionCookieName; -import java.net.URISyntaxException; -import java.time.Duration; -import java.util.Collections; import org.apache.shiro.crypto.CryptoException; import org.apache.shiro.ee.filters.Forms.FallbackPredicate; -import static org.apache.shiro.ee.filters.FormResubmitSupportCookies.initializeCookies; -import static org.apache.shiro.ee.filters.FormResubmitSupportCookies.transformCookieHeader; -import static org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isFormResubmitBlacklistEnabled; import static org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isFormResubmitDisabled; import java.io.IOException; -import java.net.CookieManager; import java.net.URI; import java.net.URLDecoder; -import java.net.http.HttpClient; -import java.net.http.HttpHeaders; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; +import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.util.List; import java.util.Objects; import java.util.Optional; -import java.util.Set; +import java.util.StringJoiner; import java.util.UUID; -import static java.util.function.Predicate.not; import static org.apache.shiro.ee.listeners.IniEnvironment.hasFacesContext; import static org.apache.shiro.web.filter.authz.PortFilter.DEFAULT_HTTP_PORT; import static org.apache.shiro.web.filter.authz.PortFilter.HTTP_SCHEME; @@ -67,7 +48,9 @@ import java.util.function.Consumer; import java.util.regex.Pattern; import java.util.stream.Collectors; +import jakarta.faces.context.FacesContext; import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletException; import jakarta.servlet.ServletRequest; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -103,7 +86,6 @@ public class FormResubmitSupport { static final String SHIRO_FORM_DATA_KEY = "org.apache.shiro.form-data-key"; static final String SESSION_EXPIRED_PARAMETER = "org.apache.shiro.sessionExpired"; static final String FORM_IS_RESUBMITTED = "org.apache.shiro.form-is-resubmitted"; - static final String FORM_RESUBMIT_BLACKLIST = "org.apache.shiro.form-resubmit-blacklist"; static final String FORM_DATA_CACHE = "org.apache.shiro.form-data-cache"; // encoded view state private static final String FACES_VIEW_STATE = "jakarta.faces.ViewState"; @@ -111,26 +93,6 @@ public class FormResubmitSupport { private static final Pattern VIEW_STATE_PATTERN = Pattern.compile(String.format("(.*)(%s-?\\d+:-?\\d+)(.*)", FACES_VIEW_STATE_EQUALS)); private static final String FACES_SOURCE = "jakarta.faces.source"; - private static final String FACES_SOURCE_EQUALS = FACES_SOURCE + "="; - static final Pattern FACES_SOURCE_PATTERN - = Pattern.compile(String.format("&?%s([\\w\\s:%%d]*)(.*)", FACES_SOURCE_EQUALS)); - private static final Pattern PARTIAL_REQUEST_PATTERN - = Pattern.compile("&?(%s.\\w+|%s.\\w+|%s)=[\\w\\s:%%d]*".formatted( - "jakarta.faces.partial", "jakarta.faces.behavior", FACES_SOURCE)); - private static final Pattern INITIAL_AMPERSAND = Pattern.compile("^&"); - private static final String FORM_RESUBMIT_HOST = "org.apache.shiro.form-resubmit-host"; - private static final String FORM_RESUBMIT_PORT = "org.apache.shiro.form-resubmit-port"; - private static final Optional RESUBMIT_HOST = Optional.ofNullable(System.getProperty(FORM_RESUBMIT_HOST)); - private static final Optional RESUBMIT_PORT = Optional.ofNullable(System.getProperty(FORM_RESUBMIT_PORT)) - .map(Integer::valueOf); - private static final String FORM_RESUBMIT_BLACK_LIST_MAX_SIZE = "org.apache.shiro.form-resubmit-blacklist-max-size"; - private static final Optional RESUBMIT_BLACK_LIST_MAX_SIZE = - Optional.ofNullable(System.getProperty(FORM_RESUBMIT_BLACK_LIST_MAX_SIZE)).map(Integer::valueOf); - private static final String FORM_RESUBMIT_BLACK_LIST_TTL_SECONDS = - "org.apache.shiro.form-resubmit-blacklist-ttl-seconds"; - private static final Optional RESUBMIT_BLACK_LIST_TTL_SECONDS = - Optional.ofNullable(System.getProperty(FORM_RESUBMIT_BLACK_LIST_TTL_SECONDS)).map(Long::valueOf); - private static final long DEFAULT_RESUBMIT_BLACK_LIST_TTL_SECONDS = 60L; private static final String SEC_FETCH_SITE = "Sec-Fetch-Site"; private static final String ORIGIN = "Origin"; private static final String CACHE_CONTROL = "Cache-Control"; @@ -138,10 +100,6 @@ public class FormResubmitSupport { private static final String PRAGMA = "Pragma"; private static final String EXPIRES = "Expires"; private static final String NO_CACHE = "no-cache"; - private static final Set SECURITY_HEADERS = - Set.of("Content-Security-Policy", "Content-Security-Policy-Report-Only", - "X-Content-Type-Options", "Referrer-Policy", "X-Frame-Options", - "Cross-Origin-Opener-Policy", "Strict-Transport-Security"); static class HttpMethod { static final String GET = "GET"; @@ -151,19 +109,15 @@ static class HttpMethod { static class HttpHeaderConstants { static final String CONTENT_TYPE = "Content-Type"; static final String LOCATION = "Location"; - static final String COOKIE = "Cookie"; - static final String SET_COOKIE = "Set-Cookie"; } static class MediaType { - static final String APPLICATION_FORM_URLENCODED = "application/x-www-form-urlencoded"; static final String TEXT_XML = "text/xml"; } static class HttpResponseCodes { static final int OK = 200; static final int FOUND = 302; - static final int AUTHFAIL = 401; } @RequiredArgsConstructor @@ -204,11 +158,8 @@ static void savePostDataForResubmit(HttpServletRequest request, HttpServletRespo } static boolean isPostRequest(ServletRequest request) { - if (request instanceof HttpServletRequest) { - return HttpMethod.POST.equalsIgnoreCase(WebUtils.toHttp(request).getMethod()); - } else { - return false; - } + return request instanceof HttpServletRequest + && HttpMethod.POST.equalsIgnoreCase(WebUtils.toHttp(request).getMethod()); } @SneakyThrows(IOException.class) @@ -327,7 +278,7 @@ static String normalizeSavedRequest(String savedRequest, HttpServletRequest requ * @param fallbackPath * @param resubmit if true, attempt to resubmit the form that was unsubmitted prior to logout */ - @SneakyThrows({IOException.class, InterruptedException.class}) + @SneakyThrows({IOException.class, ServletException.class}) static void redirectToSaved(HttpServletRequest request, HttpServletResponse response, FallbackPredicate useFallbackPath, String fallbackPath, boolean resubmit) { String savedRequest = normalizeSavedRequest(decrypt(Servlets.getRequestCookie(request, WebUtils.SAVED_REQUEST_KEY), @@ -356,7 +307,7 @@ static void redirectToSaved(HttpServletRequest request, HttpServletResponse resp private static void doRedirectToSaved(HttpServletRequest request, HttpServletResponse response, - @NonNull String savedRequest, boolean resubmit) throws IOException, InterruptedException { + @NonNull String savedRequest, boolean resubmit) throws IOException, ServletException { deleteCookie(response, request.getServletContext(), WebUtils.SAVED_REQUEST_KEY); String savedFormDataKeyString = Servlets.getRequestCookie(request, SHIRO_FORM_DATA_KEY); boolean doRedirectAtEnd = true; @@ -446,187 +397,114 @@ static boolean isLoginUrl(HttpServletRequest request) { static String resubmitSavedForm(@NonNull String savedFormData, @NonNull String rawSavedRequest, HttpServletRequest originalRequest, HttpServletResponse originalResponse, ServletContext servletContext, boolean rememberedAjaxResubmit, boolean redirect) - throws InterruptedException, IOException { - if (log.isDebugEnabled()) { - log.debug("saved form data: {}", savedFormData); - log.debug("Set Cookie Headers: {}", originalResponse.getHeaders(SET_COOKIE)); - log.debug("Original Request Headers: {}", Collections.list(originalRequest.getHeaderNames())); - log.debug("Original Request Cookie Header: {}", Collections.list(originalRequest.getHeaders(COOKIE))); - } - if (Boolean.TRUE.toString().equals(originalRequest.getHeader(FORM_IS_RESUBMITTED))) { - log.debug("Form resubmit: internal auth failure"); - setNoStoreHeaders(originalResponse); - originalResponse.setStatus(AUTHFAIL); - return resubmitResponseCleanup(originalRequest); + throws ServletException, IOException { + if (FormResubmitRequest.isResubmit(originalRequest)) { + throw new ServletException("Recursive form resubmission"); } String savedRequest = normalizeSavedRequest(rawSavedRequest, originalRequest); if (savedRequest == null) { log.debug("Form resubmit: rejecting saved request"); return originalRequest.getContextPath(); } - URI overriddenRequestURI = overrideSavedRequestURI( - URI.create(Servlets.getRequestBaseURL(originalRequest)).resolve(savedRequest)); - var cookieManager = new CookieManager(); - HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)) - .cookieHandler(cookieManager).build(); - if (isBlacklisted(overriddenRequestURI.getAuthority(), servletContext)) { - return savedRequest; - } - initializeCookies(overriddenRequestURI, servletContext, cookieManager, originalRequest); - HttpResponse response; - PartialAjaxResult decodedFormData; - try { - decodedFormData = parseFormData(savedFormData, overriddenRequestURI, client, servletContext); - HttpRequest postRequest = constructPostRequest(overriddenRequestURI, decodedFormData.result); - response = sendResubmitRequest(client, postRequest); - } catch (IOException e) { - putBlacklistEntry(overriddenRequestURI.getAuthority(), servletContext); - log.warn("Unable to resubmit form to {}{}" - + "perhaps set org.apache.shiro.form-resubmit-host or " - + "org.apache.shiro.form-resubmit-port system property?", - overriddenRequestURI, System.lineSeparator(), e); - return savedRequest; - } - if (rememberedAjaxResubmit && !decodedFormData.isStatelessRequest) { - HttpRequest redirectRequest = constructPostRequest(overriddenRequestURI, savedFormData); - var redirectResponse = client.send(redirectRequest, HttpResponse.BodyHandlers.ofString()); - log.debug("Redirect request: {}, response: {}", redirectRequest, redirectResponse); - return processResubmitResponse(redirectResponse, originalRequest, originalResponse, - response.headers(), savedRequest, servletContext, - true, true, redirect); + String dispatchPath = getDispatchPath(savedRequest, originalRequest); + if (dispatchPath == null) { + return originalRequest.getContextPath(); + } + // These must be written before the final forward can commit the response. + deleteCookie(originalResponse, servletContext, SHIRO_FORM_DATA_KEY); + setNoStoreHeaders(originalResponse); + PartialAjaxResult formData = parseFormData(savedFormData, dispatchPath, originalRequest, + originalResponse, servletContext); + boolean doubleSubmit = rememberedAjaxResubmit && !formData.isStatelessRequest; + if (formData.isPartialAjaxRequest || doubleSubmit) { + var response = new FormResubmitResponse(originalResponse, true); + forward(dispatchPath, originalRequest, response, HttpMethod.POST, formData.result); + if (doubleSubmit && (response.getStatus() == OK || response.getStatus() == FOUND)) { + // This second POST only obtains redirect handling for the expired Ajax view. + // Its flash cookie must not replace the successful POST's messages. + response = new FormResubmitResponse(originalResponse, false); + forward(dispatchPath, originalRequest, response, HttpMethod.POST, savedFormData); + } + processResubmitResponse(response, originalResponse, savedRequest, rememberedAjaxResubmit, redirect); } else { - deleteCookie(originalResponse, servletContext, SHIRO_FORM_DATA_KEY); - return processResubmitResponse(response, originalRequest, originalResponse, - response.headers(), savedRequest, servletContext, - decodedFormData.isPartialAjaxRequest, rememberedAjaxResubmit, redirect); + forward(dispatchPath, originalRequest, originalResponse, HttpMethod.POST, formData.result); + } + if (hasFacesContext()) { + Faces.responseComplete(); } + return null; } - @SneakyThrows(URISyntaxException.class) - private static URI overrideSavedRequestURI(URI savedRequestURI) { - if (RESUBMIT_HOST.isPresent() || RESUBMIT_PORT.isPresent()) { - var uri = new URI(savedRequestURI.getScheme(), savedRequestURI.getRawUserInfo(), - RESUBMIT_HOST.orElse(savedRequestURI.getHost()), RESUBMIT_PORT.orElse(savedRequestURI.getPort()), - savedRequestURI.getRawPath(), savedRequestURI.getRawQuery(), savedRequestURI.getRawFragment()); - log.debug("Form Resubmit - Overriding URI {} with {}", savedRequestURI, uri); - return uri; - } else { - return savedRequestURI; - } - } - - private static HttpRequest constructPostRequest(URI request, String body) { - return HttpRequest.newBuilder().uri(request) - .timeout(Duration.ofSeconds(5)) - .POST(HttpRequest.BodyPublishers.ofString(body)) - .headers(CONTENT_TYPE, APPLICATION_FORM_URLENCODED, - FORM_IS_RESUBMITTED, Boolean.TRUE.toString()) - .build(); - } - - private static HttpResponse - sendResubmitRequest(HttpClient client, HttpRequest request) throws IOException, InterruptedException { - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - if (log.isDebugEnabled()) { - log.debug("Resubmit request: {}, response: {}", request, response); - log.debug("Response Headers: {}", response.headers().map()); - } - if (response.statusCode() == AUTHFAIL) { - log.debug("processing authfail"); - var cookieManager = (CookieManager) client.cookieHandler().get(); - cookieStreamFromHeader(response.headers().allValues(SET_COOKIE)) - .forEach(cookie -> cookieManager.getCookieStore().add(request.uri(), cookie)); - response = client.send(request, HttpResponse.BodyHandlers.ofString()); - if (log.isDebugEnabled()) { - log.debug("Resubmit request(authfail): {}, response: {}", request, response); - log.debug("Response Headers(authfail): {}", response.headers().map()); + private static String getDispatchPath(String savedRequest, HttpServletRequest request) { + String path = savedRequest.substring(request.getContextPath().length()); + if (path.isEmpty() || path.startsWith("?")) { + path = "/" + path; + } + // A dispatcher can reach these directories, unlike the browser request being replayed. + String decodedPath = URI.create(path).getPath(); + if (Pattern.compile("^/(WEB-INF|META-INF)([/;].*)?$", Pattern.CASE_INSENSITIVE).matcher(decodedPath).matches()) { + return null; + } + return path; + } + + private static void forward(String path, HttpServletRequest originalRequest, HttpServletResponse response, + String method, String body) throws ServletException, IOException { + var dispatcher = originalRequest.getServletContext().getRequestDispatcher(path); + if (dispatcher == null) { + throw new ServletException("No request dispatcher for saved form path: " + path); + } + var request = new FormResubmitRequest(originalRequest, method, body); + // FacesServlet creates/releases its own context. Restore a calling JSF login action afterwards. + FacesContext context = hasFacesContext() ? Faces.getContext() : null; + try { + if (context != null) { + FacesContextAccess.restore(null); + } + dispatcher.forward(request, response); + } finally { + if (context != null) { + FacesContextAccess.restore(context); } } - return response; } - private static PartialAjaxResult parseFormData(String savedFormData, URI savedRequest, - HttpClient client, ServletContext servletContext) throws IOException, InterruptedException { + private abstract static class FacesContextAccess extends FacesContext { + static void restore(FacesContext context) { + setCurrentInstance(context); + } + } + + private static PartialAjaxResult parseFormData(String savedFormData, String path, + HttpServletRequest request, HttpServletResponse response, ServletContext servletContext) + throws IOException, ServletException { boolean isStateless = true; if (!isJSFClientStateSavingMethod(servletContext)) { String decodedFormData = URLDecoder.decode(savedFormData, StandardCharsets.UTF_8); if (isJSFStatefulForm(decodedFormData)) { isStateless = false; - savedFormData = getJSFNewViewState(savedRequest, client, decodedFormData); + savedFormData = getJSFNewViewState(path, request, response, savedFormData); } } return noJSFAjaxRequests(savedFormData, isStateless); } - @SuppressWarnings({"fallthrough", "checkstyle:ParameterNumber"}) - private static String processResubmitResponse(HttpResponse response, - HttpServletRequest originalRequest, HttpServletResponse originalResponse, - HttpHeaders headers, String savedRequest, ServletContext servletContext, - boolean isPartialAjaxRequest, boolean rememberedAjaxResubmit, boolean redirect) throws IOException { - switch (response.statusCode()) { - case FOUND: - if (rememberedAjaxResubmit) { - originalResponse.setStatus(OK); - } else { - // can't use Faces.redirect() here - originalResponse.setStatus(response.statusCode()); - originalResponse.setHeader(LOCATION, response.headers().firstValue(LOCATION).orElseThrow()); - } - case OK: - propagateCacheHeaders(response, originalResponse); - // do not duplicate the session cookie(s) - transformCookieHeader(headers.allValues(SET_COOKIE)) - .entrySet().stream().filter(not(entry -> entry.getKey() - .startsWith(getSessionCookieName(servletContext, getSecurityManager())))) - .forEach(entry -> addCookie(originalResponse, servletContext, - entry.getKey(), entry.getValue())); - if ((response.statusCode() == FOUND || redirect) && isPartialAjaxRequest) { - originalResponse.setHeader(CONTENT_TYPE, TEXT_XML); - originalResponse.setCharacterEncoding(StandardCharsets.UTF_8.name()); - originalResponse.getWriter().append(String.format( - "", - Encode.forXmlAttribute(savedRequest))); - } else { - response.headers().firstValue(CONTENT_TYPE).ifPresent(originalResponse::setContentType); - originalResponse.getWriter().append(response.body()); - } - return resubmitResponseCleanup(originalRequest); - default: - return savedRequest; - } - } - - private static String resubmitResponseCleanup(HttpServletRequest originalRequest) { - originalRequest.setAttribute(DONT_ADD_ANY_MORE_COOKIES, Boolean.TRUE); - if (hasFacesContext()) { - Faces.responseComplete(); - } - return null; - } - - private static void propagateCacheHeaders(HttpResponse response, HttpServletResponse originalResponse) { - HttpHeaders upstreamHeaders = response.headers(); - - List cacheControlValues = upstreamHeaders.allValues(CACHE_CONTROL); - originalResponse.setHeader(CACHE_CONTROL, cacheControlValues.isEmpty() - ? NO_STORE : String.join(", ", cacheControlValues)); - - List pragmaValues = upstreamHeaders.allValues(PRAGMA); - originalResponse.setHeader(PRAGMA, pragmaValues.isEmpty() - ? NO_CACHE : String.join(", ", pragmaValues)); - - List expiresValues = upstreamHeaders.allValues(EXPIRES); - if (expiresValues.isEmpty()) { - originalResponse.setDateHeader(EXPIRES, 0); + private static void processResubmitResponse(FormResubmitResponse response, HttpServletResponse originalResponse, + String savedRequest, boolean rememberedAjaxResubmit, boolean redirect) throws IOException { + int status = response.getStatus(); + originalResponse.setStatus(rememberedAjaxResubmit && status == FOUND ? OK : status); + if (status == FOUND || status == OK && redirect) { + if (rememberedAjaxResubmit) { + originalResponse.setHeader(LOCATION, null); + } + originalResponse.setHeader(CONTENT_TYPE, TEXT_XML); + originalResponse.setCharacterEncoding(StandardCharsets.UTF_8.name()); + originalResponse.getWriter().append(String.format( + "", + Encode.forXmlAttribute(savedRequest))); } else { - originalResponse.setHeader(EXPIRES, expiresValues.get(expiresValues.size() - 1)); + originalResponse.getOutputStream().write(response.getBuffer()); } - - upstreamHeaders.map().forEach((name, values) -> { - if (SECURITY_HEADERS.stream().anyMatch(name::equalsIgnoreCase)) { - values.forEach(v -> originalResponse.addHeader(name, v)); - } - }); } private static void setNoStoreHeaders(HttpServletResponse response) { @@ -635,65 +513,6 @@ private static void setNoStoreHeaders(HttpServletResponse response) { response.setDateHeader(EXPIRES, 0); } - static Cache getBlacklistCache(DefaultSecurityManager securityManager) { - if (securityManager == null || securityManager.getCacheManager() == null) { - return null; - } - return securityManager.getCacheManager().getCache(FORM_RESUBMIT_BLACKLIST); - } - - private static void putBlacklistEntry(String authority, ServletContext servletContext) { - var blacklist = getBlacklistCache(getDefaultSecurityManager()); - if (blacklist != null && (servletContext == null || isFormResubmitBlacklistEnabled(servletContext))) { - if (blacklist.get(authority) == null) { - @SuppressWarnings("checkstyle:MagicNumber") - int maxSize = RESUBMIT_BLACK_LIST_MAX_SIZE.orElse(1000); - if (blacklist.size() >= maxSize) { - log.warn("Form resubmit blacklist exceeded max size of {}. Clearing blacklist.", maxSize); - blacklist.clear(); - } - } - blacklist.put(authority, System.currentTimeMillis()); - } - } - - private static DefaultSecurityManager getDefaultSecurityManager() { - if (!isSecurityManagerTypeOf(getSecurityManager(), DefaultSecurityManager.class)) { - log.debug("Shiro SecurityManager is not configured for form resubmit blacklist caching"); - return null; - } - DefaultSecurityManager dsm = getSecurityManager(DefaultSecurityManager.class); - if (dsm.getCacheManager() == null) { - log.debug("Shiro Cache manager is not configured, cannot cache form resubmit blacklist state"); - return null; - } - return dsm; - } - - static boolean isBlacklisted(String authority, ServletContext servletContext) { - long currentTimeMillis = System.currentTimeMillis(); - return isBlacklisted(getBlacklistCache(getDefaultSecurityManager()), servletContext, authority, - Duration.ofSeconds(RESUBMIT_BLACK_LIST_TTL_SECONDS.orElse(DEFAULT_RESUBMIT_BLACK_LIST_TTL_SECONDS)), - currentTimeMillis); - } - - static boolean isBlacklisted(Cache blacklist, ServletContext servletContext, String authority, - Duration ttl, long currentTimeMillis) { - if (blacklist == null || (servletContext != null && !isFormResubmitBlacklistEnabled(servletContext))) { - return false; - } - Long blacklistedAt = blacklist.get(authority); - if (blacklistedAt == null) { - return false; - } - boolean active = blacklistedAt >= currentTimeMillis - || currentTimeMillis - blacklistedAt < ttl.toMillis(); - if (!active) { - blacklist.remove(authority); - } - return active; - } - public static DefaultWebSessionManager getNativeSessionManager(SecurityManager securityManager) { DefaultWebSessionManager rv = null; SecurityManager unwrapped = unwrapSecurityManager(securityManager, SecurityManager.class, type -> false); @@ -714,12 +533,23 @@ static AbstractRememberMeManager getRememberMeManager() { return null; } - private static String getJSFNewViewState(URI savedRequest, HttpClient client, String savedFormData) - throws IOException, InterruptedException { - var getRequest = HttpRequest.newBuilder().uri(savedRequest).GET().build(); - HttpResponse htmlResponse = sendResubmitRequest(client, getRequest); - if (htmlResponse.statusCode() == OK) { - savedFormData = extractJSFNewViewState(htmlResponse.body(), savedFormData); + private static String getJSFNewViewState(String path, HttpServletRequest request, + HttpServletResponse response, String savedFormData) throws IOException, ServletException { + var htmlResponse = new FormResubmitResponse(response, true); + forward(path, request, htmlResponse, HttpMethod.GET, ""); + if (htmlResponse.getStatus() == OK) { + String html = htmlResponse.getBufferAsString(); + // Decode only the view-state field: decoding the entire body corrupts escaped &, + and = in user input. + savedFormData = java.util.Arrays.stream(savedFormData.split("&", -1)).map(field -> { + String[] pair = field.split("=", 2); + if (pair.length == 2 && FACES_VIEW_STATE.equals(URLDecoder.decode(pair[0], StandardCharsets.UTF_8))) { + String updated = extractJSFNewViewState(html, + FACES_VIEW_STATE_EQUALS + URLDecoder.decode(pair[1], StandardCharsets.UTF_8)); + return pair[0] + "=" + URLEncoder.encode(updated.substring(FACES_VIEW_STATE_EQUALS.length()), + StandardCharsets.UTF_8); + } + return field; + }).collect(Collectors.joining("&")); } return savedFormData; } @@ -740,18 +570,24 @@ static String extractJSFNewViewState(@NonNull String responseBody, @NonNull Stri } static PartialAjaxResult noJSFAjaxRequests(String savedFormData, boolean isStateless) { - var partialMatcher = PARTIAL_REQUEST_PATTERN.matcher(savedFormData); - boolean hasPartialAjax = partialMatcher.find(); + boolean hasPartialAjax = false; String appendFacesSourceString = ""; - if (hasPartialAjax) { - var facesSourceMatcher = FACES_SOURCE_PATTERN.matcher(savedFormData); - if (facesSourceMatcher.find()) { - appendFacesSourceString = "&%s=".formatted(facesSourceMatcher.group(1)); + var fullForm = new StringJoiner("&"); + for (String field : savedFormData.split("&")) { + String[] pair = field.split("=", 2); + String name = URLDecoder.decode(pair[0], StandardCharsets.UTF_8); + boolean isSource = FACES_SOURCE.equals(name); + if (isSource || name.startsWith("jakarta.faces.partial.") || name.startsWith("jakarta.faces.behavior.")) { + hasPartialAjax = true; + if (isSource && pair.length == 2 && !pair[1].isEmpty()) { + // The source value becomes the submitted command's parameter name, still URL-encoded. + appendFacesSourceString = "&" + pair[1] + "="; + } + } else if (!field.isEmpty()) { + fullForm.add(field); } } - - return new PartialAjaxResult((isStateless ? savedFormData : INITIAL_AMPERSAND.matcher(partialMatcher - .replaceAll("")).replaceFirst("")) + return new PartialAjaxResult((isStateless ? savedFormData : fullForm.toString()) + appendFacesSourceString, hasPartialAjax, isStateless); } diff --git a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitSupportCookies.java b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitSupportCookies.java index d4f381a4a8..d7ca01a91a 100644 --- a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitSupportCookies.java +++ b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitSupportCookies.java @@ -13,31 +13,17 @@ */ package org.apache.shiro.ee.filters; -import static org.apache.shiro.SecurityUtils.getSecurityManager; -import static org.apache.shiro.ee.cdi.ShiroScopeContext.isWebContainerSessions; import static org.apache.shiro.ee.filters.FormResubmitSupport.getNativeSessionManager; -import java.net.CookieManager; -import java.net.HttpCookie; -import java.net.URI; import java.time.Duration; -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.Stream; import jakarta.servlet.ServletContext; import jakarta.servlet.ServletRequest; import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.AccessLevel; import lombok.NoArgsConstructor; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; -import org.apache.shiro.SecurityUtils; import org.apache.shiro.ee.listeners.EnvironmentLoaderListener; -import static org.apache.shiro.web.mgt.CookieRememberMeManager.DEFAULT_REMEMBER_ME_COOKIE_NAME; -import static org.apache.shiro.web.servlet.ShiroHttpSession.DEFAULT_SESSION_ID_NAME; /** * Cookie Support methods @@ -46,8 +32,6 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) @SuppressWarnings("HideUtilityClassConstructor") public class FormResubmitSupportCookies { - static final String DONT_ADD_ANY_MORE_COOKIES = "org.apache.shiro.no-more-cookies"; - static void addCookie(@NonNull HttpServletResponse response, ServletContext servletContext, @NonNull String cookieName, @NonNull String cookieValue, int maxAge, boolean httpOnly) { var cookie = new Cookie(cookieName, cookieValue); @@ -60,18 +44,6 @@ static void addCookie(@NonNull HttpServletResponse response, ServletContext serv response.addCookie(cookie); } - static void addCookie(@NonNull HttpServletResponse response, ServletContext servletContext, - @NonNull String cookieName, @NonNull HttpCookie inputCookie) { - var cookie = new Cookie(cookieName, inputCookie.getValue()); - cookie.setPath(inputCookie.getPath() != null ? inputCookie.getPath() : servletContext.getContextPath()); - cookie.setMaxAge(Math.toIntExact(inputCookie.getMaxAge())); - cookie.setHttpOnly(inputCookie.isHttpOnly()); - if (EnvironmentLoaderListener.isFormResubmitSecureCookies(servletContext)) { - cookie.setSecure(true); - } - response.addCookie(cookie); - } - static void deleteCookie(@NonNull HttpServletResponse response, ServletContext servletContext, @NonNull String cookieName) { var cookieToDelete = new Cookie(cookieName, "tbd"); @@ -98,47 +70,4 @@ static int getCookieAge(ServletRequest request, org.apache.shiro.mgt.SecurityMan } } - static String getSessionCookieName(ServletContext context, org.apache.shiro.mgt.SecurityManager securityManager) { - if (!isWebContainerSessions(securityManager) && getNativeSessionManager(securityManager) != null) { - return getNativeSessionManager(securityManager).getSessionIdCookie().getName(); - } else { - return context.getSessionCookieConfig().getName() != null - ? context.getSessionCookieConfig().getName() : DEFAULT_SESSION_ID_NAME; - } - } - - static Map transformCookieHeader(@NonNull List cookies) { - return cookieStreamFromHeader(cookies) - .collect(Collectors.toMap(HttpCookie::getName, Function.identity(), (var, v2) -> v2)); - } - - static Stream cookieStreamFromHeader(@NonNull List cookies) { - return cookies.stream().map(HttpCookie::parse).map(list -> list.get(0)); - } - - static void initializeCookies(URI savedRequest, ServletContext servletContext, - CookieManager cookieManager, HttpServletRequest originalRequest) { - var session = SecurityUtils.getSubject().getSession(); - var sessionCookieName = getSessionCookieName(servletContext, getSecurityManager()); - var sessionCookie = new HttpCookie(sessionCookieName, session.getId().toString()); - sessionCookie.setPath(servletContext.getContextPath()); - sessionCookie.setVersion(0); - cookieManager.getCookieStore().add(savedRequest, sessionCookie); - log.debug("Setting Cookie {}", sessionCookieName); - for (Cookie origCookie : originalRequest.getCookies()) { - if (!origCookie.getName().startsWith(sessionCookieName) - && !origCookie.getName().equals(DEFAULT_REMEMBER_ME_COOKIE_NAME)) { - try { - log.debug("Setting Cookie {}", origCookie.getName()); - HttpCookie cookie = new HttpCookie(origCookie.getName(), origCookie.getValue()); - cookie.setPath(servletContext.getContextPath()); - cookie.setVersion(0); - cookieManager.getCookieStore().add(savedRequest, cookie); - } catch (IllegalArgumentException e) { - log.warn("Form Resubmit: Ignoring invalid cookie [{} - {}]", - origCookie.getName(), origCookie.getValue(), e); - } - } - } - } } diff --git a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/Forms.java b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/Forms.java index 6286397eae..9086ce9c6e 100644 --- a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/Forms.java +++ b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/Forms.java @@ -15,7 +15,6 @@ import static org.apache.shiro.ee.filters.FormAuthenticationFilter.LOGIN_PREDICATE_ATTR_NAME; import static org.apache.shiro.ee.filters.FormAuthenticationFilter.LOGIN_WAITTIME_ATTR_NAME; -import static org.apache.shiro.ee.filters.FormResubmitSupport.FORM_IS_RESUBMITTED; import static org.apache.shiro.ee.filters.FormResubmitSupport.SESSION_EXPIRED_PARAMETER; import static org.apache.shiro.ee.filters.LogoutFilter.LOGOUT_PREDICATE_ATTR_NAME; import static org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isFormResubmitDisabled; @@ -199,7 +198,7 @@ public static void logout(FallbackPredicate useFallback, String fallbackPath) { public static void logout(HttpServletRequest request, HttpServletResponse response, FallbackPredicate useFallback, String fallbackPath) { if (SecurityUtils.getSubject().isRemembered() - || !Boolean.TRUE.toString().equals(request.getHeader(FORM_IS_RESUBMITTED))) { + || !FormResubmitRequest.isResubmit(request)) { SecurityUtils.getSubject().logout(); FormResubmitSupport.redirectToView(request, response, useFallback, fallbackPath); } 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 0576b3d99d..2c00dd3413 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 @@ -19,7 +19,6 @@ import static org.apache.shiro.ee.filters.FormResubmitSupport.isJSFClientStateSavingMethod; import static org.apache.shiro.ee.filters.FormResubmitSupport.isPostRequest; import static org.apache.shiro.ee.filters.FormResubmitSupport.resubmitSavedForm; -import static org.apache.shiro.ee.filters.FormResubmitSupportCookies.DONT_ADD_ANY_MORE_COOKIES; import static org.apache.shiro.ee.listeners.EnvironmentLoaderListener.getCharacterEncoding; import static org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isCharEncodingEnabled; import static org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isShiroEEDisabled; @@ -36,7 +35,6 @@ import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; import jakarta.servlet.annotation.WebFilter; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponseWrapper; @@ -52,12 +50,14 @@ import org.apache.shiro.session.SessionException; import org.apache.shiro.subject.Subject; import org.apache.shiro.subject.SubjectContext; +import org.apache.shiro.SecurityUtils; import static org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isShiroEERedirectDisabled; import static org.apache.shiro.web.filter.authz.SslFilter.HTTPS_SCHEME; import org.apache.shiro.web.mgt.DefaultWebSecurityManager; import org.apache.shiro.web.mgt.WebSecurityManager; import org.apache.shiro.web.servlet.ShiroHttpServletRequest; import org.apache.shiro.web.session.mgt.WebSessionKey; +import org.apache.shiro.web.subject.WebSubject; import org.apache.shiro.web.subject.WebSubjectContext; import org.apache.shiro.web.util.WebUtils; import org.omnifaces.util.Servlets; @@ -145,13 +145,6 @@ private static class WrappedResponse extends HttpServletResponseWrapper { this.request = request; } - @Override - public void addCookie(Cookie cookie) { - if (request.getAttribute(DONT_ADD_ANY_MORE_COOKIES) != Boolean.TRUE) { - super.addCookie(cookie); - } - } - @Override public void sendRedirect(String location) throws IOException { if (!Utils.startsWithOneOf(location, "http://", "https://") @@ -233,7 +226,16 @@ public void setSecurityManager(WebSecurityManager sm) { } @Override - @SneakyThrows(InterruptedException.class) + protected WebSubject createSubject(ServletRequest request, ServletResponse response) { + if (FormResubmitRequest.isResubmit(request) && SecurityUtils.getSubject() instanceof WebSubject subject) { + // The new session cookie need not have reached the browser yet (notably with native sessions). + // Reuse identity, not the security chain: executeChain still resolves the forwarded target. + return subject; + } + return super.createSubject(request, response); + } + + @Override protected void executeChain(ServletRequest request, ServletResponse response, FilterChain origChain) throws IOException, ServletException { if (isShiroEEDisabled(getServletContext())) { @@ -244,7 +246,7 @@ protected void executeChain(ServletRequest request, ServletResponse response, String postData = getPostData(request); log.debug("Resubmitting Post Data: {}", postData); var httpRequest = WebUtils.toHttp(request); - boolean rememberedAjaxResubmit = "partial/ajax".equals(httpRequest.getHeader("Faces-Request")); + boolean rememberedAjaxResubmit = Servlets.isFacesAjaxRequest(httpRequest); Optional.ofNullable(resubmitSavedForm(postData, Servlets.getRequestURIWithQueryString(httpRequest), WebUtils.toHttp(request), WebUtils.toHttp(response), 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 cc47fade83..fa4b0a08f0 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 @@ -46,7 +46,6 @@ public class EnvironmentLoaderListener extends EnvironmentLoader implements Serv 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_SECURE_COOKIES = "org.apache.shiro.form-resubmit.secure-cookies"; - private static final String FORM_RESUBMIT_BLACK_LIST_DISABLED = "org.apache.shiro.form-resubmit.blacklist.disabled"; private static final String SHIRO_WEB_DISABLE_PRINCIPAL_PARAM = "org.apache.shiro.web.disable-principal"; public static boolean isShiroEEDisabled(ServletContext ctx) { @@ -65,10 +64,6 @@ public static boolean isFormResubmitSecureCookies(ServletContext ctx) { return Boolean.TRUE.equals(ctx.getAttribute(FORM_RESUBMIT_SECURE_COOKIES)); } - public static boolean isFormResubmitBlacklistEnabled(ServletContext ctx) { - return !Boolean.TRUE.equals(ctx.getAttribute(FORM_RESUBMIT_BLACK_LIST_DISABLED)); - } - public static boolean isServletNoPrincipal(ServletContext ctx) { return Boolean.TRUE.equals(ctx.getAttribute(SHIRO_WEB_DISABLE_PRINCIPAL_PARAM)); } @@ -101,9 +96,6 @@ public void contextInitialized(ServletContextEvent sce) { } else { sce.getServletContext().setAttribute(FORM_RESUBMIT_SECURE_COOKIES, Boolean.FALSE); } - if (Boolean.parseBoolean(sce.getServletContext().getInitParameter(FORM_RESUBMIT_BLACK_LIST_DISABLED))) { - sce.getServletContext().setAttribute(FORM_RESUBMIT_BLACK_LIST_DISABLED, Boolean.TRUE); - } if (Boolean.parseBoolean(sce.getServletContext().getInitParameter(SHIRO_WEB_DISABLE_PRINCIPAL_PARAM))) { sce.getServletContext().setAttribute(SHIRO_WEB_DISABLE_PRINCIPAL_PARAM, Boolean.TRUE); } diff --git a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormSupportTest.java b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormSupportTest.java index afb1d2d2de..1ef51c400a 100644 --- a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormSupportTest.java +++ b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormSupportTest.java @@ -13,24 +13,14 @@ */ package org.apache.shiro.ee.filters; -import jakarta.servlet.ServletContext; import org.apache.shiro.ee.filters.FormResubmitSupport.PartialAjaxResult; -import org.apache.shiro.cache.MemoryConstrainedCacheManager; -import static org.apache.shiro.ee.filters.FormResubmitSupport.FACES_SOURCE_PATTERN; import static org.apache.shiro.ee.filters.FormResubmitSupport.extractJSFNewViewState; import static org.apache.shiro.ee.filters.FormResubmitSupport.getReferer; import static org.apache.shiro.ee.filters.FormResubmitSupport.isJSFStatefulForm; import static org.apache.shiro.ee.filters.FormResubmitSupport.noJSFAjaxRequests; -import static org.apache.shiro.ee.filters.FormResubmitSupportCookies.transformCookieHeader; - -import java.net.HttpCookie; import java.net.URLDecoder; -import java.time.Duration; import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; import jakarta.servlet.http.HttpServletRequest; import static org.assertj.core.api.Assertions.assertThat; @@ -43,7 +33,6 @@ import static org.mockito.Mockito.when; import org.mockito.junit.jupiter.MockitoExtension; -import org.apache.shiro.mgt.DefaultSecurityManager; /** * Resubmit forms support @@ -51,13 +40,8 @@ @ExtendWith(MockitoExtension.class) @SuppressWarnings("checkstyle:MethodCount") class FormSupportTest { - private static final long BLACKLISTED_AT = 1_000L; - private static final Duration BLACKLIST_TTL = Duration.ofSeconds(60); - @Mock private HttpServletRequest request; - @Mock - private ServletContext servletContext; @Test void nullReferer() { @@ -271,17 +255,16 @@ void noAjaxRequests() { } @Test - void parseFacesSources() { - var matcher = FACES_SOURCE_PATTERN.matcher("j_idt12=j_idt12&j_idt12:j_idt14=asdf&j_idt12:j_idt16=asdf" - + "&jakarta.faces.ViewState=7709788254588873136:-8052771455757429917" - + "&jakarta.faces.source=j_idt12:j_idt18" + void encodedAjaxFieldsAreRemovedCompletely() { + var result = noJSFAjaxRequests("text=a%26b%2Bc%3Dd&jakarta.faces.ViewState=123%3A456" + + "&jakarta.faces.source=j_idt12%3Aj_idt18" + "&jakarta.faces.partial.event=click" - + "&jakarta.faces.partial.execute=j_idt12:j_idt18 j_idt12" - + "&jakarta.faces.partial.render=j_idt12" + + "&jakarta.faces.partial.execute=j_idt12%3Aj_idt18+j_idt12" + + "&jakarta.faces.partial.render=j_idt12%20%40all" + "&jakarta.faces.behavior.event=action" - + "&jakarta.faces.partial.ajax=false"); - assertThat(matcher.find()).isTrue(); - assertThat(matcher.group(1)).isEqualTo("j_idt12:j_idt18"); + + "&jakarta.faces.partial.ajax=true", false); + assertThat(result).isEqualTo(new PartialAjaxResult( + "text=a%26b%2Bc%3Dd&jakarta.faces.ViewState=123%3A456&j_idt12%3Aj_idt18=", true, false)); } @Test @@ -328,69 +311,6 @@ void clientSideStateSavingNoAjax() { &jakarta.faces.partial.ajax=true&secondForm:submitSecond=""".replace("\n", "")); } - @Test - void parseCookies() { - var map = Map.of("name1", "value1", "name2", "value2", "name3", "value3") - .entrySet().stream() - .collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, - entry -> { - var cookie = new HttpCookie(entry.getKey(), entry.getValue()); - if (entry.getKey().equals("name2")) { - cookie.setPath("/my/path"); - } - return cookie; - })); - - assertThat(transformCookieHeader(List.of("name1=value1", "name2=value2; path=/my/path", "name3=value3"))).isEqualTo(map); - assertThat(transformCookieHeader(List.of("name="))).isEqualTo(Map.of("name", new HttpCookie("name", ""))); - assertThat(transformCookieHeader(List.of("JSESSIONID=\"abc\"; $Version=\"1\"; $Path=\"/mypath\""))) - .isEqualTo(Map.of("JSESSIONID", new HttpCookie("JSESSIONID", "abc"))); - } - - @Test - @SuppressWarnings("checkstyle:MagicNumber") - void blacklistUseShiroCacheManager() { - var securityManager = new DefaultSecurityManager(); - securityManager.setCacheManager(new MemoryConstrainedCacheManager()); - - var blacklist = FormResubmitSupport.getBlacklistCache(securityManager); - - blacklist.put("bad.example", BLACKLISTED_AT); - - assertThat(FormResubmitSupport.isBlacklisted(blacklist, null, "bad.example", - BLACKLIST_TTL, 1_500L)).isTrue(); - } - - @Test - @SuppressWarnings("checkstyle:MagicNumber") - void expiredBlacklistEntryIsRemovedFromShiroCache() { - var securityManager = new DefaultSecurityManager(); - securityManager.setCacheManager(new MemoryConstrainedCacheManager()); - - var blacklist = FormResubmitSupport.getBlacklistCache(securityManager); - blacklist.put("expired.example", BLACKLISTED_AT); - - assertThat(FormResubmitSupport.isBlacklisted(blacklist, null, "expired.example", - BLACKLIST_TTL, 61_001L)).isFalse(); - assertThat(blacklist.get("expired.example")).isNull(); - } - - @Test - @SuppressWarnings("checkstyle:MagicNumber") - void blacklistHonoursEnabledFlag() { - var securityManager = new DefaultSecurityManager(); - securityManager.setCacheManager(new MemoryConstrainedCacheManager()); - var blacklist = FormResubmitSupport.getBlacklistCache(securityManager); - blacklist.put("bad.example", BLACKLISTED_AT); - - // attribute absent → enabled - assertThat(FormResubmitSupport.isBlacklisted(blacklist, servletContext, "bad.example", - BLACKLIST_TTL, 1_500L)).isTrue(); - - when(servletContext.getAttribute("org.apache.shiro.form-resubmit.blacklist.disabled")).thenReturn(Boolean.TRUE); - assertThat(FormResubmitSupport.isBlacklisted(blacklist, servletContext, "bad.example", - BLACKLIST_TTL, 1_500L)).isFalse(); - } private static String decode(String plain) { return URLDecoder.decode(plain, StandardCharsets.UTF_8);