diff --git a/integration-tests/jakarta-ee/src/main/webapp/shiro/auth/loginform.xhtml b/integration-tests/jakarta-ee/src/main/webapp/shiro/auth/loginform.xhtml index aae1c52897..34547a40a9 100644 --- a/integration-tests/jakarta-ee/src/main/webapp/shiro/auth/loginform.xhtml +++ b/integration-tests/jakarta-ee/src/main/webapp/shiro/auth/loginform.xhtml @@ -21,7 +21,8 @@ xmlns:f="jakarta.faces.core" xmlns:h="jakarta.faces.html" xmlns:jsf="jakarta.faces" - xmlns:p="jakarta.faces.passthrough"> + xmlns:p="jakarta.faces.passthrough" + xmlns:shiro="http://shiro.apache.org/tags"> Please Log In @@ -36,6 +37,7 @@

Remember Me:

+ 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..8404a08477 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 @@ -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; @@ -48,6 +47,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.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.support.FindBy; @@ -123,6 +123,12 @@ public class ShiroAuthFormsIT { @FindBy(id = "loginFailureMessage") private WebElement loginFailureMessage; + @FindBy(className = "shiro-form-data-notice") + private WebElement formDataNotice; + + @FindBy(name = "org.apache.shiro.form-data.discard") + private WebElement discardFormData; + @BeforeEach void deleteAllCookies() { webDriver.manage().deleteAllCookies(); @@ -222,13 +228,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 +251,37 @@ 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 formDataNoticeOnlyWhenFormDataIsSaved() { + webDriver.get(baseURL + "shiro/protected"); + assertThat(webDriver.findElements(By.className("shiro-form-data-notice"))).isEmpty(); + nonAjaxSessionExpired(); + assertThat(formDataNotice.getText()).startsWith("We saved the form data you entered"); + assertThat(discardFormData.isSelected()).isFalse(); + } + + @Test + @OperateOnDeployment(DEPLOYMENT_DEV_MODE) + void discardSavedFormData() { + nonAjaxSessionExpired(); + discardFormData.click(); + login(); + assertThat(webDriver.getTitle()).isEqualTo("Form Page"); + assertThat(webDriver.findElements(By.id("messages"))).as("no form data submitted").isEmpty(); + assertThat(firstName.getAttribute("value")).isEmpty(); + webDriver.get(baseURL + "shiro/auth/loginform"); + assertThat(webDriver.findElements(By.className("shiro-form-data-notice"))).as("discarded").isEmpty(); + } + @Test @OperateOnDeployment(DEPLOYMENT_DEV_MODE) void nonAjaxResubmitAfterFailedLogin() { @@ -304,26 +345,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"); diff --git a/support/jakarta-ee/README.adoc b/support/jakarta-ee/README.adoc index b82cae49f5..99f3335860 100644 --- a/support/jakarta-ee/README.adoc +++ b/support/jakarta-ee/README.adoc @@ -12,7 +12,9 @@ * limitations under the License. //// -Shrio-EE feature set += Shiro Jakarta EE integration + +== Feature set - Able to Annotate CDI and EJB (local & remote) beans with @RequiresXXX and thus protect them @@ -28,3 +30,107 @@ throws it) - Works with JSF Client state saving and Server state saving - Basically, it’s Shiro on steroids when used with Jakarta EE stack as opposed to changing the way Shiro works by integrating CDI + +== 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. Saved form +parameters replace those of the login request. Replay targets must read form +data through the parameter accessors; the raw request body is not reconstructed. + +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. Response metadata setters (such as content type and encoding) and +generic header read-back retain the wrapped response's behavior. +Captured state from view-state GETs and failed attempts is discarded instead +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: +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. + +=== 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 +submitted once they sign in, and offer to discard it instead, which covers a +shared browser where someone else signs in next. Inside the login form: + +[source,xml] +---- +xmlns:shiro="http://shiro.apache.org/tags" +... + +---- + +This renders nothing unless form data is actually waiting. Otherwise it shows +_"We saved the form data you entered before signing in. It will be submitted +for you once you sign in."_ and a _"Discard my form data"_ checkbox. The +`message` and `discardLabel` attributes override the wording; the +`shiro-form-data-notice`, `shiro-form-data-message` and +`shiro-form-data-discard` CSS classes allow styling. + +For a custom layout, the same building blocks are available on the `authc` +bean: `#{authc.formDataSaved}`, `#{authc.savedFormDataPath}` (the page the +data will be submitted to) and `#{authc.discardFormDataParameter}`, the name +for a plain checkbox. Outside Faces, use `Forms.isFormDataSaved(request)`, +`Forms.getSavedFormDataPath(request)` and `Forms.DISCARD_FORM_DATA_PARAMETER`. +When that parameter arrives with the login request, from either the Faces or the +plain-servlet login, the saved form data is deleted and the user is just taken +to the saved page. + +=== 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. +Response `reset()` does not support switching between a writer and an output stream. + +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. 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..88864543a4 --- /dev/null +++ b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitRequest.java @@ -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 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 attributes = new HashMap<>(); + + @SuppressWarnings("unused") + private interface Parameters { + String getParameter(String name); + String[] getParameterValues(String name); + Enumeration getParameterNames(); + Map getParameterMap(); + } + + FormResubmitRequest(HttpServletRequest request, String method, Map> formFields, + AjaxReplay ajaxReplay) { + super(unwrap(request)); + this.method = method; + this.ajaxReplay = ajaxReplay; + parameters = new MutableRequest(request) { + @Override + public Map> 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 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); + } +} 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..d9ce0ec1a7 --- /dev/null +++ b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitResponse.java @@ -0,0 +1,142 @@ +/* + * 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 org.apache.shiro.ee.filters.FormResubmitSupport.HttpHeaderConstants.LOCATION; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletResponseWrapper; +import java.io.IOException; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import lombok.Getter; +import lombok.Setter; +import lombok.SneakyThrows; +import lombok.experimental.Delegate; +import org.omnifaces.servlet.BufferedHttpServletResponse; + +/** + * Captures a replay's status, headers, cookies and body instead of committing them to the browser. + * Nothing reaches the browser response until {@link #applyTo} is called for a successful replay, + * so view-state GETs, failed attempts and Faces error handling (which resets the response) + * cannot disturb what the login request has already written, such as session cookies. + */ +final class FormResubmitResponse extends BufferedHttpServletResponse { + private final List> deferred = new ArrayList<>(); + private final @Delegate(types = Captured.class) HttpServletResponse recorder; + private @Getter @Setter int status = SC_OK; + + /** + * Header and cookie operations are recorded, to be replayed by {@link #applyTo}. + * Redirects and errors are captured as status (and Location header) instead, + * while content lengths are dropped since the body may be translated by the caller. + */ + @SuppressWarnings("unused") + private interface Captured { + void setHeader(String name, String value); + void addHeader(String name, String value); + void setIntHeader(String name, int value); + void addIntHeader(String name, int value); + void setDateHeader(String name, long date); + void addDateHeader(String name, long date); + void addCookie(Cookie cookie); + void sendRedirect(String location) throws IOException; + void sendRedirect(String location, int sc) throws IOException; + void sendRedirect(String location, boolean clearBuffer) throws IOException; + void sendRedirect(String location, int sc, boolean clearBuffer) throws IOException; + void sendError(int sc) throws IOException; + void sendError(int sc, String message) throws IOException; + void setContentLength(int len); + void setContentLengthLong(long len); + } + + FormResubmitResponse(HttpServletResponse response) { + super(new UncommittedResponse(response)); + recorder = (HttpServletResponse) Proxy.newProxyInstance(getClass().getClassLoader(), + new Class[] {HttpServletResponse.class}, this::defer); + } + + /** + * Replays the captured header and cookie operations onto the browser response. + * Status and body are left to the caller, which may translate them for the original client. + * + * @param target the browser response + */ + void applyTo(HttpServletResponse target) { + deferred.forEach(op -> op.accept(target)); + } + + private Object defer(Object proxy, Method method, Object[] args) { + switch (method.getName()) { + case "sendRedirect" -> redirect(args); + case "sendError" -> setStatus((int) args[0]); + case "setContentLength", "setContentLengthLong" -> { } + case "equals", "hashCode", "toString" -> + throw new IllegalStateException("Cannot compare FormResubmitResponse instances"); + default -> deferred.add(target -> invoke(method, target, args)); + } + return null; + } + + /** + * @param args of the (location [, status] [, clearBuffer]) overloads + */ + private void redirect(Object[] args) { + if (!(args[args.length - 1] instanceof Boolean clearBuffer) || clearBuffer) { + resetBuffer(); + } + setStatus(args.length > 1 && args[1] instanceof Integer sc ? sc : SC_FOUND); + setHeader(LOCATION, (String) args[0]); + } + + @SneakyThrows + private static void invoke(Method method, HttpServletResponse target, Object[] args) { + MethodHandles.publicLookup().unreflect(method).bindTo(target).invokeWithArguments(args); + } + + @Override + public void resetBuffer() { + // the base class only resets its own buffer here, since the wrapped response ignores reset() + super.reset(); + } + + @Override + public void reset() { + super.reset(); + status = SC_OK; + deferred.clear(); + } + + /** + * Shields the browser response from a replay's commit-type operations, + * that would otherwise reset or flush what the login request has already written. + */ + private static final class UncommittedResponse extends HttpServletResponseWrapper { + UncommittedResponse(HttpServletResponse response) { + super(response); + } + + @Override + public void reset() { + } + + @Override + public void flushBuffer() { + } + } +} 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..9b6bad8476 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 @@ -19,45 +19,32 @@ import static org.apache.shiro.SecurityUtils.isSecurityManagerTypeOf; 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 jakarta.faces.component.behavior.ClientBehaviorContext.BEHAVIOR_SOURCE_PARAM_NAME; +import static jakarta.faces.context.PartialViewContext.ALL_PARTIAL_PHASE_CLIENT_IDS; +import static jakarta.faces.context.PartialViewContext.PARTIAL_RENDER_PARAM_NAME; +import static jakarta.servlet.http.HttpServletResponse.SC_FOUND; +import static jakarta.servlet.http.HttpServletResponse.SC_OK; 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.cookieName; 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.isFormResubmitAjaxRenderAllDisabled; 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.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.Set; 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,17 +54,16 @@ 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; import lombok.AccessLevel; -import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.NonNull; -import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; -import lombok.ToString; import lombok.extern.slf4j.Slf4j; import org.apache.shiro.cache.Cache; import org.apache.shiro.lang.codec.Base64; @@ -88,10 +74,12 @@ import org.apache.shiro.web.session.mgt.DefaultWebSessionManager; import org.apache.shiro.web.util.WebUtils; import org.jsoup.Jsoup; +import org.jsoup.parser.Parser; import org.jsoup.select.Elements; import org.omnifaces.util.Faces; +import org.omnifaces.util.ResourcePaths; import org.omnifaces.util.Servlets; -import org.owasp.encoder.Encode; +import org.omnifaces.util.Utils; /** * supporting methods for {@link Forms} @@ -103,45 +91,14 @@ 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"; - private static final String FACES_VIEW_STATE_EQUALS = FACES_VIEW_STATE + "="; - 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 Pattern STATEFUL_VIEW_STATE_PATTERN = Pattern.compile("-?\\d+:-?\\d+"); + private static final String FACES_PARTIAL_PREFIX = "jakarta.faces.partial."; + 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 CACHE_CONTROL = "Cache-Control"; - private static final String NO_STORE = "no-store"; - 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"; @@ -149,32 +106,66 @@ 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"; + /** + * Where a saved form's replay is triggered from + */ + enum ReplayFlow { + /** the browser is still on the saved form's page, whose submission found the session expired */ + IN_PLACE, + /** the browser is on the login page, having just authenticated */ + AFTER_LOGIN } - static class HttpResponseCodes { - static final int OK = 200; - static final int FOUND = 302; - static final int AUTHFAIL = 401; - } + /** + * How a saved Faces Ajax submission is replayed + */ + enum AjaxReplay { + /** as a full-page submission of the same command, for a browser that isn't awaiting the partial response */ + FULL_PAGE, + /** as-is, with its partial response passed through to the waiting Ajax client */ + PASS_THROUGH, + /** passed through, re-rendering the whole rebuilt view so the page resynchronizes with the server */ + RENDER_ALL; + + /** + * @return the replay the original request calls for, before the saved form itself is considered + */ + static AjaxReplay of(ReplayFlow flow, HttpServletRequest request) { + if (flow != ReplayFlow.IN_PLACE || !Servlets.isFacesAjaxRequest(request)) { + return FULL_PAGE; + } + return isFormResubmitAjaxRenderAllDisabled(request.getServletContext()) ? PASS_THROUGH : RENDER_ALL; + } - @RequiredArgsConstructor - @EqualsAndHashCode @ToString - @SuppressWarnings("VisibilityModifier") - static class PartialAjaxResult { - public final String result; - public final boolean isPartialAjaxRequest; - public final boolean isStatelessRequest; + /** + * @return this replay narrowed to the saved form: only a Faces Ajax submission can be passed through, + * and a stateless view isn't rebuilt, so there is nothing to resynchronize + */ + AjaxReplay forForm(boolean isPartialAjaxRequest, boolean isStateless) { + if (!isPartialAjaxRequest) { + return FULL_PAGE; + } + return isStateless && this == RENDER_ALL ? PASS_THROUGH : this; + } + + boolean isPassThrough() { + return this != FULL_PAGE; + } } + /** + * Form fields prepared for replay + * + * @param result decoded form fields, by name + * @param isPartialAjaxRequest whether the saved form was submitted via Faces Ajax + * @param ajaxReplay how the form is replayed + */ + record PartialAjaxResult(Map> result, boolean isPartialAjaxRequest, + AjaxReplay ajaxReplay) { } + static void savePostDataForResubmit(HttpServletRequest request, HttpServletResponse response, @NonNull String loginUrl) { if (isPostRequest(request) && isSecurityManagerTypeOf(getSecurityManager(), DefaultSecurityManager.class) && shouldSavePostData(request)) { @@ -204,11 +195,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) @@ -311,8 +299,7 @@ static String normalizeSavedRequest(String savedRequest, HttpServletRequest requ && !path.startsWith(contextPath + "/")) { return null; } - String query = uri.getRawQuery(); - return query == null ? rawPath : rawPath + "?" + query; + return Utils.formatURLWithQueryString(rawPath, uri.getRawQuery()); } catch (IllegalArgumentException e) { return null; } @@ -321,17 +308,15 @@ static String normalizeSavedRequest(String savedRequest, HttpServletRequest requ /** * Redirects the user to saved request after login, if available * Resubmits the form that caused the logout upon successful login.Form resubmission supports JSF and Ajax forms - * @param request - * @param response + * @param request the HTTP servlet request + * @param response the HTTP servlet response * @param useFallbackPath predicate whether to use fall back path - * @param fallbackPath + * @param fallbackPath the fallback path to use if no saved request is found * @param resubmit if true, attempt to resubmit the form that was unsubmitted prior to logout */ - @SneakyThrows({IOException.class, InterruptedException.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), - getRememberMeManager()), request); + String savedRequest = getSavedRequest(request); if (savedRequest != null) { doRedirectToSaved(request, response, savedRequest, resubmit); } else { @@ -343,10 +328,10 @@ static void redirectToSaved(HttpServletRequest request, HttpServletResponse resp * redirect to saved request, possibly resubmitting an existing form * the saved request is via a cookie * - * @param request - * @param response - * @param useFallbackPath - * @param fallbackPath + * @param request the HTTP servlet request + * @param response the HTTP servlet response + * @param useFallbackPath predicate whether to use fall back path + * @param fallbackPath the fallback path to use if no saved request is found */ static void redirectToSaved(HttpServletRequest request, HttpServletResponse response, FallbackPredicate useFallbackPath, String fallbackPath) { @@ -355,23 +340,63 @@ static void redirectToSaved(HttpServletRequest request, HttpServletResponse resp } + /** + * @param request the HTTP servlet request + * @return the saved request path from the request's cookie, normalized to this context, or null + */ + static String getSavedRequest(HttpServletRequest request) { + return normalizeSavedRequest(decrypt(Servlets.getRequestCookie(request, WebUtils.SAVED_REQUEST_KEY), + getRememberMeManager()), request); + } + + /** + * @param request the HTTP servlet request + * @return the saved form data's cache key from the request's cookie, or null if absent or malformed + */ + static UUID getSavedFormDataKey(HttpServletRequest request) { + String key = Servlets.getRequestCookie(request, cookieName(request.getServletContext(), SHIRO_FORM_DATA_KEY)); + try { + return key == null ? null : UUID.fromString(key); + } catch (IllegalArgumentException e) { + log.debug("Ignoring malformed saved form data key cookie", e); + return null; + } + } + + /** + * @param request the HTTP servlet request + * @return true if the request's browser has form data saved, waiting to be submitted after login + */ + static boolean hasSavedFormData(HttpServletRequest request) { + UUID savedFormDataKey = getSavedFormDataKey(request); + return savedFormDataKey != null && getSavedFormDataFromKey(savedFormDataKey, cache -> { }) != null; + } + + /** + * @param request the HTTP servlet request + * @return true if the request asks for saved form data to be discarded rather than submitted, + * via the {@link Forms#DISCARD_FORM_DATA_PARAMETER} parameter, e.g. from a checked login-page checkbox + */ + static boolean isFormDataDiscarded(HttpServletRequest request) { + String discard = request.getParameter(Forms.DISCARD_FORM_DATA_PARAMETER); + return discard != null && !Boolean.FALSE.toString().equalsIgnoreCase(discard); + } + private static void doRedirectToSaved(HttpServletRequest request, HttpServletResponse response, - @NonNull String savedRequest, boolean resubmit) throws IOException, InterruptedException { - deleteCookie(response, request.getServletContext(), WebUtils.SAVED_REQUEST_KEY); - String savedFormDataKeyString = Servlets.getRequestCookie(request, SHIRO_FORM_DATA_KEY); + @NonNull String savedRequest, boolean resubmit) { + deleteCookie(response, request.getServletContext(), WebUtils.SAVED_REQUEST_KEY, false); + UUID savedFormDataKey = getSavedFormDataKey(request); boolean doRedirectAtEnd = true; - if (savedFormDataKeyString != null && resubmit) { + if (savedFormDataKey != null) { AtomicReference> cache = new AtomicReference<>(); - UUID savedFormDataKey = UUID.fromString(savedFormDataKeyString); String formData = getSavedFormDataFromKey(savedFormDataKey, cache::set); try { - if (formData != null) { - Optional.ofNullable(resubmitSavedForm(formData, savedRequest, request, response, - request.getServletContext(), false, true)) - .ifPresent(path -> doFacesRedirect(request, response, path)); + if (formData != null && resubmit && !isFormDataDiscarded(request)) { + resubmitSavedForm(formData, savedRequest, request, response, ReplayFlow.AFTER_LOGIN); doRedirectAtEnd = false; } else { - deleteCookie(response, request.getServletContext(), SHIRO_FORM_DATA_KEY); + // nothing to submit, or the user chose to discard it: forget it either way + deleteCookie(response, request.getServletContext(), SHIRO_FORM_DATA_KEY, true); } } finally { if (cache.get() != null) { @@ -385,8 +410,8 @@ private static void doRedirectToSaved(HttpServletRequest request, HttpServletRes } /** - * @param request - * @param response + * @param request the HTTP servlet request + * @param response the HTTP servlet response */ static void redirectToView(HttpServletRequest request, HttpServletResponse response) { redirectToView(request, response, (path, req) -> false, null); @@ -396,10 +421,10 @@ static void redirectToView(HttpServletRequest request, HttpServletResponse respo * redirects to current view after a form submit, * or the fallback path if predicate succeeds * - * @param request - * @param response - * @param useFallbackPath - * @param fallbackPath + * @param request the HTTP servlet request + * @param response the HTTP servlet response + * @param useFallbackPath predicate whether to use fall back path + * @param fallbackPath the fallback path to use if no saved request is found */ @SneakyThrows static void redirectToView(HttpServletRequest request, HttpServletResponse response, @@ -424,10 +449,10 @@ static void redirectToView(HttpServletRequest request, HttpServletResponse respo /** * flash cookie is preserved here * - * @param request - * @param response - * @param path - * @param paramValues + * @param request the HTTP servlet request + * @param response the HTTP servlet response + * @param path the path to redirect to + * @param paramValues the parameters to include in the redirect */ private static void doFacesRedirect(HttpServletRequest request, HttpServletResponse response, String path, Object... paramValues) { @@ -443,255 +468,180 @@ static boolean isLoginUrl(HttpServletRequest request) { return loginUrl != null && request.getRequestURI().equals(request.getContextPath() + loginUrl); } - 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); - } - 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); - } else { - deleteCookie(originalResponse, servletContext, SHIRO_FORM_DATA_KEY); - return processResubmitResponse(response, originalRequest, originalResponse, - response.headers(), savedRequest, servletContext, - decodedFormData.isPartialAjaxRequest, rememberedAjaxResubmit, redirect); + /** + * Replays a saved form in-process, via a request dispatcher forward, and writes the outcome to the response. + * Any replay fault is treated as optional and quietly falls back to a plain redirect to the saved request. + * + * @param savedRequest already validated by {@link #normalizeSavedRequest} + */ + static void resubmitSavedForm(@NonNull String savedFormData, @NonNull String savedRequest, + HttpServletRequest originalRequest, HttpServletResponse originalResponse, ReplayFlow flow) { + if (!replaySavedForm(savedFormData, savedRequest, originalRequest, originalResponse, flow)) { + doFacesRedirect(originalRequest, originalResponse, savedRequest); } } - @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()); - } + /** + * @return whether the response has been fully written + */ + private static boolean replaySavedForm(String savedFormData, String savedRequest, + HttpServletRequest originalRequest, HttpServletResponse originalResponse, ReplayFlow flow) { + if (FormResubmitRequest.isResubmit(originalRequest)) { + log.debug("Recursive form resubmission, skipping replay"); + return false; } - return response; - } - - private static PartialAjaxResult parseFormData(String savedFormData, URI savedRequest, - HttpClient client, ServletContext servletContext) throws IOException, InterruptedException { - boolean isStateless = true; - if (!isJSFClientStateSavingMethod(servletContext)) { - String decodedFormData = URLDecoder.decode(savedFormData, StandardCharsets.UTF_8); - if (isJSFStatefulForm(decodedFormData)) { - isStateless = false; - savedFormData = getJSFNewViewState(savedRequest, client, decodedFormData); - } + String dispatchPath = getDispatchPath(savedRequest, originalRequest); + var servletContext = originalRequest.getServletContext(); + if (dispatchPath == null || servletContext.getRequestDispatcher(dispatchPath) == null) { + log.debug("Form resubmit: rejecting dispatch path for {}", savedRequest); + return false; } - 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; + // These must be written before the replayed response is committed by processResubmitResponse() + deleteCookie(originalResponse, servletContext, SHIRO_FORM_DATA_KEY, true); + Servlets.setNoCacheHeaders(originalRequest, originalResponse); + try { + var savedFormFields = parseFormData(savedFormData, getFormCharset(originalRequest, servletContext)); + PartialAjaxResult formData = prepareFormData(savedFormFields, dispatchPath, originalRequest, + originalResponse, servletContext, AjaxReplay.of(flow, originalRequest)); + var response = new FormResubmitResponse(originalResponse); + forward(dispatchPath, originalRequest, response, HttpMethod.POST, formData.result, formData.ajaxReplay); + if (isFailed(response.getStatus())) { + log.debug("Form resubmit to {} failed with status {}", dispatchPath, response.getStatus()); + return false; + } + if (formData.ajaxReplay.isPassThrough() && isPartialResponseError(response.getBufferAsString())) { + log.debug("Form resubmit to {} failed with a Faces Ajax error", dispatchPath); + return false; + } + processResubmitResponse(response, originalRequest, originalResponse, savedRequest, + formData.isPartialAjaxRequest, flow); + } catch (ServletException | IOException | RuntimeException e) { + log.warn("Unable to resubmit form to {}", dispatchPath, e); + return false; } - } - - private static String resubmitResponseCleanup(HttpServletRequest originalRequest) { - originalRequest.setAttribute(DONT_ADD_ANY_MORE_COOKIES, Boolean.TRUE); if (hasFacesContext()) { Faces.responseComplete(); } - return null; + return true; } - 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); - } else { - originalResponse.setHeader(EXPIRES, expiresValues.get(expiresValues.size() - 1)); - } - - upstreamHeaders.map().forEach((name, values) -> { - if (SECURITY_HEADERS.stream().anyMatch(name::equalsIgnoreCase)) { - values.forEach(v -> originalResponse.addHeader(name, v)); - } - }); + private static boolean isFailed(int status) { + return status != SC_OK && status != SC_FOUND; } - private static void setNoStoreHeaders(HttpServletResponse response) { - response.setHeader(CACHE_CONTROL, NO_STORE); - response.setHeader(PRAGMA, NO_CACHE); - response.setDateHeader(EXPIRES, 0); + /** + * Faces reports an unhandled exception in an Ajax request as a successful partial response that carries + * an error element, which would reach only the Ajax client's error callback. + * Such a replay is considered failed, so that it falls back to a redirect like a full-page one. + */ + static boolean isPartialResponseError(@NonNull String responseBody) { + return Jsoup.parse(responseBody, Parser.xmlParser()).selectFirst("partial-response > error") != null; } - static Cache getBlacklistCache(DefaultSecurityManager securityManager) { - if (securityManager == null || securityManager.getCacheManager() == null) { + /** + * Derives the dispatcher path from a saved request that is already verified to be within the context path. + * The container's dispatcher strips path parameters, decodes and normalizes the path before resolving it, + * which could otherwise reach the WEB-INF and META-INF directories that are inaccessible to browsers. + * Hence, the same is mirrored here, and the resulting path is checked. + * + * @return path and query to dispatch to, or null if rejected + */ + static String getDispatchPath(@NonNull String savedRequest, HttpServletRequest request) { + URI uri = URI.create(savedRequest); + String path = ResourcePaths.addLeadingSlashIfNecessary( + uri.getRawPath().substring(request.getContextPath().length()).replaceAll(";[^/]*", "")); + // trailing slash makes a trailing "." or ".." segment resolvable, and matches directories exactly + String resolvedPath = WebUtils.normalize(ResourcePaths.addTrailingSlashIfNecessary( + Utils.decodeURL(path).replace('\\', '/'))); + if (resolvedPath == null + || Utils.startsWithOneOf(resolvedPath.toUpperCase(Locale.ROOT), "/WEB-INF/", "/META-INF/")) { return null; } - return securityManager.getCacheManager().getCache(FORM_RESUBMIT_BLACKLIST); + return Utils.formatURLWithQueryString(path, uri.getRawQuery()); } - 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(); - } + /** + * @param path dispatch path already verified by {@link #replaySavedForm} to resolve to a dispatcher + */ + private static void forward(String path, HttpServletRequest originalRequest, HttpServletResponse response, + String method, Map> formFields, AjaxReplay ajaxReplay) + throws ServletException, IOException { + var request = new FormResubmitRequest(originalRequest, method, formFields, ajaxReplay); + var dispatcher = originalRequest.getServletContext().getRequestDispatcher(path); + if (!hasFacesContext()) { + dispatcher.forward(request, response); + } else { + // FacesServlet creates/releases its own context. Restore a calling Faces login action's afterward. + FacesContext context = Faces.getContext(); + Faces.setContext(null); + try { + dispatcher.forward(request, response); + } finally { + Faces.setContext(context); } - 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; + /** + * Parses {@code application/x-www-form-urlencoded} data into decoded fields, by name. + * Unlike {@link Servlets#toParameterMap(String)}, empty values are preserved, + * since Faces treats an empty field differently from an absent one. + */ + static Map> parseFormData(@NonNull String formData, @NonNull Charset charset) { + var formFields = new LinkedHashMap>(); + for (String field : formData.split("&")) { + if (!field.isEmpty()) { + String[] pair = field.split("=", 2); + formFields.computeIfAbsent(URLDecoder.decode(pair[0], charset), name -> new ArrayList<>()) + .add(pair.length == 2 ? URLDecoder.decode(pair[1], charset) : ""); + } } - return dsm; + return formFields; } - 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); + /** + * @return the encoding the container would have used for the saved form's parameters + */ + static Charset getFormCharset(HttpServletRequest request, ServletContext servletContext) { + return Optional.ofNullable(request.getCharacterEncoding()) + .or(() -> Optional.ofNullable(servletContext.getRequestCharacterEncoding())) + .map(encoding -> { + try { + return Charset.forName(encoding); + } catch (IllegalArgumentException e) { + log.debug("Ignoring unsupported request encoding {}", encoding, e); + return null; + } + }).orElse(StandardCharsets.UTF_8); + } + + private static PartialAjaxResult prepareFormData(Map> savedFormFields, String path, + HttpServletRequest request, HttpServletResponse response, ServletContext servletContext, + AjaxReplay ajaxReplay) throws IOException, ServletException { + boolean isStateless = isJSFClientStateSavingMethod(servletContext) || !isJSFStatefulForm(savedFormFields); + var formFields = new LinkedHashMap<>(savedFormFields); + if (!isStateless) { + refreshJSFViewState(path, request, response, formFields); + } + return noJSFAjaxRequests(formFields, isStateless, ajaxReplay); } - 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); + /** + * Only a successful replay reaches the browser, with its headers and cookies. + * An Ajax replay's partial response is passed through to the Ajax client that submitted the form. + * Otherwise, the Ajax client is redirected to see the full-page replay's outcome. + */ + private static void processResubmitResponse(FormResubmitResponse response, HttpServletRequest originalRequest, + HttpServletResponse originalResponse, String savedRequest, boolean isPartialAjaxRequest, + ReplayFlow flow) throws IOException { + int status = response.getStatus(); + response.applyTo(originalResponse); + originalResponse.setStatus(status); + if (isPartialAjaxRequest && (status == SC_FOUND || flow == ReplayFlow.AFTER_LOGIN)) { + doFacesRedirect(originalRequest, originalResponse, savedRequest); + } else { + originalResponse.getOutputStream().write(response.getBuffer()); } - return active; } public static DefaultWebSessionManager getNativeSessionManager(SecurityManager securityManager) { @@ -714,51 +664,69 @@ 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 void refreshJSFViewState(String path, HttpServletRequest request, + HttpServletResponse response, Map> formFields) throws IOException, ServletException { + // view-state GET headers and cookies stay captured and are never applied to the browser response + var htmlResponse = new FormResubmitResponse(response); + forward(path, request, htmlResponse, HttpMethod.GET, Map.of(), AjaxReplay.FULL_PAGE); + if (htmlResponse.getStatus() == SC_OK) { + Optional.ofNullable(extractJSFNewViewState(htmlResponse.getBufferAsString())).ifPresent(viewState -> { + log.debug("Replaced ViewState: {}", viewState); + formFields.put(FACES_VIEW_STATE, List.of(viewState)); + }); } - return savedFormData; } - static String extractJSFNewViewState(@NonNull String responseBody, @NonNull String savedFormData) { + /** + * @return view state of the first form in the rendered view, or null if there is none + */ + static String extractJSFNewViewState(@NonNull String responseBody) { Elements elts = Jsoup.parse(responseBody).select("input[name=%s]".formatted(FACES_VIEW_STATE)); - if (!elts.isEmpty()) { - String viewState = elts.first().attr("value"); - - var matcher = VIEW_STATE_PATTERN.matcher(savedFormData); - if (matcher.matches()) { - savedFormData = matcher.replaceFirst("$1%s%s$3".formatted( - FACES_VIEW_STATE_EQUALS, viewState)); - log.debug("Encoded w/Replaced ViewState: {}", savedFormData); - } - } - return savedFormData; + return elts.isEmpty() ? null : Objects.requireNonNull(elts.first()).attr("value"); + } + + /** + * Turns a Faces Ajax submission into a full-page submission of the same command. + * The Ajax fields are only kept for stateless views, where they can't fail view state restoration. + */ + static PartialAjaxResult noJSFAjaxRequests(Map> formFields, boolean isStateless) { + return noJSFAjaxRequests(formFields, isStateless, AjaxReplay.FULL_PAGE); } - static PartialAjaxResult noJSFAjaxRequests(String savedFormData, boolean isStateless) { - var partialMatcher = PARTIAL_REQUEST_PATTERN.matcher(savedFormData); - boolean hasPartialAjax = partialMatcher.find(); - String appendFacesSourceString = ""; - if (hasPartialAjax) { - var facesSourceMatcher = FACES_SOURCE_PATTERN.matcher(savedFormData); - if (facesSourceMatcher.find()) { - appendFacesSourceString = "&%s=".formatted(facesSourceMatcher.group(1)); + /** + * Keeps a Faces Ajax submission intact when it's passed through to the waiting Ajax client, + * or when the view is stateless so the Ajax fields can't fail view state restoration. + * Otherwise, turns it into a full-page submission of the same command. + */ + static PartialAjaxResult noJSFAjaxRequests(Map> formFields, boolean isStateless, + AjaxReplay ajaxReplay) { + var fullForm = new LinkedHashMap>(); + formFields.forEach((name, values) -> { + if (!isFacesAjaxField(name)) { + fullForm.put(name, values); } + }); + boolean isPartialAjaxRequest = fullForm.size() != formFields.size(); + var replay = ajaxReplay.forForm(isPartialAjaxRequest, isStateless); + var result = isStateless || replay.isPassThrough() ? new LinkedHashMap<>(formFields) : fullForm; + if (replay == AjaxReplay.RENDER_ALL) { + result.put(PARTIAL_RENDER_PARAM_NAME, List.of(ALL_PARTIAL_PHASE_CLIENT_IDS)); } + // The source value becomes the submitted command's parameter name + formFields.getOrDefault(BEHAVIOR_SOURCE_PARAM_NAME, List.of()).stream() + .filter(source -> !Utils.isEmpty(source)).findFirst() + .ifPresent(source -> result.putIfAbsent(source, List.of(""))); + return new PartialAjaxResult(result, isPartialAjaxRequest, replay); + } - return new PartialAjaxResult((isStateless ? savedFormData : INITIAL_AMPERSAND.matcher(partialMatcher - .replaceAll("")).replaceFirst("")) - + appendFacesSourceString, hasPartialAjax, isStateless); + private static boolean isFacesAjaxField(String name) { + return BEHAVIOR_SOURCE_PARAM_NAME.equals(name) + || Utils.startsWithOneOf(name, FACES_PARTIAL_PREFIX, FACES_BEHAVIOR_PREFIX); } - static boolean isJSFStatefulForm(@NonNull String savedFormData) { - var matcher = VIEW_STATE_PATTERN.matcher(savedFormData); - return matcher.find() && matcher.groupCount() >= 2 - && !matcher.group(2).equalsIgnoreCase("stateless"); + static boolean isJSFStatefulForm(@NonNull Map> formFields) { + return formFields.getOrDefault(FACES_VIEW_STATE, List.of()).stream() + .anyMatch(viewState -> STATEFUL_VIEW_STATE_PATTERN.matcher(viewState).matches()); } static boolean isJSFClientStateSavingMethod(ServletContext servletContext) { 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..e8c403af2c 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,19 @@ */ 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 static org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isFormResubmitSecureCookies; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; 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,41 +34,47 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) @SuppressWarnings("HideUtilityClassConstructor") public class FormResubmitSupportCookies { - static final String DONT_ADD_ANY_MORE_COOKIES = "org.apache.shiro.no-more-cookies"; + private static final String HOST_PREFIX = "__Host-"; - static void addCookie(@NonNull HttpServletResponse response, ServletContext servletContext, - @NonNull String cookieName, @NonNull String cookieValue, int maxAge, boolean httpOnly) { - var cookie = new Cookie(cookieName, cookieValue); - cookie.setPath(servletContext.getContextPath()); + /** + * With secure cookies, the {@code __Host-} prefix makes browsers refuse the cookie unless this very host + * set it over HTTPS, so it can't be planted from another subdomain or over plain HTTP. + * The prefix requires {@code Path=/}, hence the context path moves into the name, URL-encoded + * (cookie-name safe) to keep co-hosted applications apart. + */ + static String cookieName(ServletContext servletContext, @NonNull String baseName) { + return isFormResubmitSecureCookies(servletContext) + ? HOST_PREFIX + baseName + URLEncoder.encode(servletContext.getContextPath(), StandardCharsets.UTF_8) + : baseName; + } + + private static Cookie newCookie(ServletContext servletContext, String baseName, String value, int maxAge, + boolean useHostPrefix) { + var cookie = new Cookie(useHostPrefix ? cookieName(servletContext, baseName) : baseName, value); + boolean secure = useHostPrefix && isFormResubmitSecureCookies(servletContext); + cookie.setPath(secure ? "/" : servletContext.getContextPath()); + cookie.setSecure(secure); cookie.setMaxAge(maxAge); - cookie.setHttpOnly(httpOnly); - if (EnvironmentLoaderListener.isFormResubmitSecureCookies(servletContext)) { - cookie.setSecure(true); - } - response.addCookie(cookie); + return cookie; } + /** + * @param cookieName base name, see {@link #cookieName} + */ 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); - } + @NonNull String cookieName, @NonNull String cookieValue, int maxAge, boolean httpOnly) { + var cookie = newCookie(servletContext, cookieName, cookieValue, maxAge, true); + cookie.setHttpOnly(httpOnly); response.addCookie(cookie); } + /** + * @param cookieName base name, see {@link #cookieName} + * @param useHostPrefix false for a plain-name, context-scoped cookie regardless of the secure-cookie setting + */ static void deleteCookie(@NonNull HttpServletResponse response, ServletContext servletContext, - @NonNull String cookieName) { - var cookieToDelete = new Cookie(cookieName, "tbd"); - cookieToDelete.setPath(servletContext.getContextPath()); - cookieToDelete.setMaxAge(0); - if (EnvironmentLoaderListener.isFormResubmitSecureCookies(servletContext)) { - cookieToDelete.setSecure(true); - } - response.addCookie(cookieToDelete); + @NonNull String cookieName, boolean useHostPrefix) { + response.addCookie(newCookie(servletContext, cookieName, "tbd", 0, useHostPrefix)); } static int getCookieAge(ServletRequest request, org.apache.shiro.mgt.SecurityManager securityManager) { @@ -97,48 +91,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..d04488c835 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; @@ -27,7 +26,6 @@ import lombok.AccessLevel; import lombok.NoArgsConstructor; import lombok.SneakyThrows; -import lombok.extern.slf4j.Slf4j; import org.apache.shiro.SecurityUtils; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.UsernamePasswordToken; @@ -40,15 +38,21 @@ * functionality includes saving a previous form state and resubmitting * if the form times out */ -@Slf4j @NoArgsConstructor(access = AccessLevel.PRIVATE) @SuppressWarnings("HideUtilityClassConstructor") public class Forms { + /** + * Request parameter that, when present on the login request (e.g. from a checked checkbox), + * discards the user's saved form data instead of submitting it after login + */ + public static final String DISCARD_FORM_DATA_PARAMETER = "org.apache.shiro.form-data.discard"; + /** * JSF access points */ @Named("authc") @ApplicationScoped + @SuppressWarnings("unused") public static class AuthenticationMethods { /** * let Shiro filter handle the login, @@ -66,8 +70,8 @@ public void login() { /** * manual login, zero wait time * - * @param username - * @param password + * @param username the username + * @param password the password */ public void login(String username, String password) { login(username, password, false); @@ -76,9 +80,9 @@ public void login(String username, String password) { /** * manual login with timeout * - * @param username - * @param password - * @param rememberMe + * @param username the username + * @param password the password + * @param rememberMe whether to remember the user */ public void login(String username, String password, boolean rememberMe) { Forms.login(username, password, rememberMe); @@ -113,6 +117,28 @@ public boolean isLoginFailure() { return Faces.getRequestAttribute(DEFAULT_ERROR_KEY_ATTRIBUTE_NAME) != null || Faces.getFlashAttribute(DEFAULT_ERROR_KEY_ATTRIBUTE_NAME) != null; } + + /** + * @return true if the user has form data saved, to be submitted after login, see {@link #getDiscardFormDataParameter()} + */ + public boolean isFormDataSaved() { + return Forms.isFormDataSaved(Faces.getRequest()); + } + + /** + * @return the path of the page whose form data is saved, or null + */ + public String getSavedFormDataPath() { + return Forms.getSavedFormDataPath(Faces.getRequest()); + } + + /** + * @return name for a login-form checkbox that lets the user discard the saved form data, + * see {@link Forms#DISCARD_FORM_DATA_PARAMETER} + */ + public String getDiscardFormDataParameter() { + return DISCARD_FORM_DATA_PARAMETER; + } } @FunctionalInterface @@ -125,8 +151,8 @@ public interface FallbackPredicate { * redirect to saved request, possibly resubmitting an existing form * the saved request is via a cookie * - * @param useFallbackPath - * @param fallbackPath + * @param useFallbackPath whether to use fallback path + * @param fallbackPath the fallback path to use if no saved request is found */ public static void redirectToSaved(FallbackPredicate useFallbackPath, String fallbackPath) { FormResubmitSupport.redirectToSaved(Faces.getRequest(), Faces.getResponse(), useFallbackPath, fallbackPath, @@ -141,15 +167,20 @@ public static void redirectToView() { FormResubmitSupport.redirectToView(Faces.getRequest(), Faces.getResponse()); } + /** + * Jakarta Faces variant + * @param useFallbackPath whether to use fallback path + * @param fallbackPath the fallback path to use if no saved request is found + */ public static void redirectToView(FallbackPredicate useFallbackPath, String fallbackPath) { FormResubmitSupport.redirectToView(Faces.getRequest(), Faces.getResponse(), useFallbackPath, fallbackPath); } /** * manually login, used via {@link PassThruAuthenticationFilter} - * @param username - * @param password - * @param rememberMe + * @param username the username + * @param password the password + * @param rememberMe whether to remember the user */ @SneakyThrows(InterruptedException.class) public static void login(String username, String password, boolean rememberMe) { @@ -175,14 +206,17 @@ public static void loginFailed() { redirectToView(); } + /** + * Jakarta Faces variant + */ public static void logout() { Forms.logout(Faces.getRequestAttribute(LOGOUT_PREDICATE_ATTR_NAME), ""); } /** - * Faces variant - * @param useFallback - * @param fallbackPath + * Jakarta Faces variant + * @param useFallback whether to use fallback path + * @param fallbackPath the fallback path to use if no saved request is found */ public static void logout(FallbackPredicate useFallback, String fallbackPath) { logout(Faces.getRequest(), Faces.getResponse(), useFallback, fallbackPath); @@ -191,15 +225,14 @@ public static void logout(FallbackPredicate useFallback, String fallbackPath) { /** * makes sure that there is no double-logout * - * @param request - * @param response - * @param useFallback - * @param fallbackPath + * @param request the HTTP servlet request + * @param response the HTTP servlet response + * @param useFallback whether to use fallback path + * @param fallbackPath the fallback path to use if no saved request is found */ 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))) { + if (SecurityUtils.getSubject().isRemembered() || !FormResubmitRequest.isResubmit(request)) { SecurityUtils.getSubject().logout(); FormResubmitSupport.redirectToView(request, response, useFallback, fallbackPath); } @@ -213,4 +246,20 @@ public static boolean isLoggedIn() { public static boolean isSessionExpired() { return wasViewExpired() || Boolean.parseBoolean(Faces.getRequestParameter(SESSION_EXPIRED_PARAMETER)); } + + /** + * @param request the HTTP servlet request + * @return true if the user has form data saved, to be submitted after login + */ + public static boolean isFormDataSaved(HttpServletRequest request) { + return FormResubmitSupport.hasSavedFormData(request); + } + + /** + * @param request the HTTP servlet request + * @return the path of the page whose form data is saved, or null + */ + public static String getSavedFormDataPath(HttpServletRequest request) { + return FormResubmitSupport.getSavedRequest(request); + } } 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..f823574a8a 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 @@ -18,8 +18,10 @@ 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.isPostRequest; +import static org.apache.shiro.ee.filters.FormResubmitSupport.normalizeSavedRequest; +import static org.apache.shiro.ee.filters.FormResubmitSupport.redirectToView; import static org.apache.shiro.ee.filters.FormResubmitSupport.resubmitSavedForm; -import static org.apache.shiro.ee.filters.FormResubmitSupportCookies.DONT_ADD_ANY_MORE_COOKIES; +import org.apache.shiro.ee.filters.FormResubmitSupport.ReplayFlow; 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; @@ -27,7 +29,6 @@ import java.io.UnsupportedEncodingException; import java.nio.charset.Charset; import java.security.Principal; -import java.util.Optional; import java.util.regex.Pattern; import jakarta.servlet.DispatcherType; import jakarta.servlet.FilterChain; @@ -36,14 +37,11 @@ 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; import lombok.AccessLevel; import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.SneakyThrows; import lombok.experimental.Delegate; import lombok.extern.slf4j.Slf4j; import static org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isServletNoPrincipal; @@ -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; @@ -65,13 +65,13 @@ /** * Stops JEE server from interpreting Shiro principal as direct EJB principal, - * this has sideffects of trying to log in to remote EJBs with the credentials from Shiro, + * this has side effects of trying to log in to remote EJBs with the credentials from Shiro, * which isn't what this meant to do, as it's meant to just transfer Shiro credentials * to remote EJB call site. - * + *

* Thus, force null EJB principal for the web session, * as the real principal comes from the EjbSecurityFilter's doAs() call - * + *

* Also handles X-Forwarded-Proto support */ @Slf4j @@ -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://") @@ -162,10 +155,8 @@ public void sendRedirect(String location) throws IOException { } } - @RequiredArgsConstructor - static class WrappedSecurityManager implements WebSecurityManager, org.apache.shiro.mgt.WrappedSecurityManager { - final @Delegate WebSecurityManager wrapped; - + record WrappedSecurityManager(@Delegate WebSecurityManager wrapped) + implements WebSecurityManager, org.apache.shiro.mgt.WrappedSecurityManager { @Override public Subject createSubject(SubjectContext context) { if (context instanceof WebSubjectContext webContext && wrapped instanceof DefaultWebSecurityManager wsm) { @@ -233,7 +224,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())) { @@ -242,25 +242,23 @@ protected void executeChain(ServletRequest request, ServletResponse response, setCharacterEncodingIfNeeded(request); request.removeAttribute(FORM_IS_RESUBMITTED); String postData = getPostData(request); - log.debug("Resubmitting Post Data: {}", postData); var httpRequest = WebUtils.toHttp(request); - boolean rememberedAjaxResubmit = "partial/ajax".equals(httpRequest.getHeader("Faces-Request")); - Optional.ofNullable(resubmitSavedForm(postData, - Servlets.getRequestURIWithQueryString(httpRequest), - WebUtils.toHttp(request), WebUtils.toHttp(response), - request.getServletContext(), rememberedAjaxResubmit, false)) - .ifPresent(url -> sendRedirect(response, url)); + 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); } } - @SneakyThrows(IOException.class) - private static void sendRedirect(ServletResponse response, String url) { - WebUtils.toHttp(response).sendRedirect(url); - } - @SuppressWarnings("LineLength") private static void setCharacterEncodingIfNeeded(ServletRequest request) throws UnsupportedEncodingException { 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..6e121c68e6 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,8 @@ 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 FORM_RESUBMIT_AJAX_RENDER_ALL_DISABLED_PARAM = + "org.apache.shiro.form-resubmit.ajax-render-all.disabled"; private static final String SHIRO_WEB_DISABLE_PRINCIPAL_PARAM = "org.apache.shiro.web.disable-principal"; public static boolean isShiroEEDisabled(ServletContext ctx) { @@ -65,8 +66,13 @@ 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)); + /** + * @param ctx servlet context + * @return whether an in-place Faces Ajax replay keeps the form's own render targets + * instead of re-rendering the whole view, which resynchronizes the page with the rebuilt server-side view + */ + public static boolean isFormResubmitAjaxRenderAllDisabled(ServletContext ctx) { + return Boolean.TRUE.equals(ctx.getAttribute(FORM_RESUBMIT_AJAX_RENDER_ALL_DISABLED_PARAM)); } public static boolean isServletNoPrincipal(ServletContext ctx) { @@ -85,15 +91,9 @@ public static Charset getCharacterEncoding(ServletContext ctx) { @Override @SuppressWarnings({"checkstyle:NPathComplexity", "checkstyle:CyclomaticComplexity"}) public void contextInitialized(ServletContextEvent sce) { - if (Boolean.parseBoolean(sce.getServletContext().getInitParameter(SHIRO_EE_DISABLED_PARAM))) { - sce.getServletContext().setAttribute(SHIRO_EE_DISABLED_PARAM, Boolean.TRUE); - } - if (Boolean.parseBoolean(sce.getServletContext().getInitParameter(SHIRO_EE_REDIRECT_DISABLED_PARAM))) { - sce.getServletContext().setAttribute(SHIRO_EE_REDIRECT_DISABLED_PARAM, Boolean.TRUE); - } - if (Boolean.parseBoolean(sce.getServletContext().getInitParameter(FORM_RESUBMIT_DISABLED_PARAM))) { - sce.getServletContext().setAttribute(FORM_RESUBMIT_DISABLED_PARAM, Boolean.TRUE); - } + copyBooleanInitParameter(sce.getServletContext(), SHIRO_EE_DISABLED_PARAM); + copyBooleanInitParameter(sce.getServletContext(), SHIRO_EE_REDIRECT_DISABLED_PARAM); + copyBooleanInitParameter(sce.getServletContext(), FORM_RESUBMIT_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)) { @@ -101,15 +101,9 @@ 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); - } - if (Boolean.parseBoolean(sce.getServletContext().getInitParameter(SHIRO_EE_DISABLE_CHAR_ENCODING_PARAM))) { - sce.getServletContext().setAttribute(SHIRO_EE_DISABLE_CHAR_ENCODING_PARAM, Boolean.TRUE); - } + copyBooleanInitParameter(sce.getServletContext(), FORM_RESUBMIT_AJAX_RENDER_ALL_DISABLED_PARAM); + copyBooleanInitParameter(sce.getServletContext(), SHIRO_WEB_DISABLE_PRINCIPAL_PARAM); + copyBooleanInitParameter(sce.getServletContext(), SHIRO_EE_DISABLE_CHAR_ENCODING_PARAM); if (sce.getServletContext().getInitParameter(SHIRO_EE_CHAR_ENCODING_PARAM) != null) { sce.getServletContext().setAttribute(SHIRO_EE_CHAR_ENCODING_PARAM, Charset.forName(sce.getServletContext().getInitParameter(SHIRO_EE_CHAR_ENCODING_PARAM))); @@ -125,6 +119,15 @@ public void contextInitialized(ServletContextEvent sce) { } } + /** + * Exposes an init parameter's {@code true} as an attribute, for the {@code is...()} methods above + */ + private static void copyBooleanInitParameter(ServletContext ctx, String name) { + if (Boolean.parseBoolean(ctx.getInitParameter(name))) { + ctx.setAttribute(name, Boolean.TRUE); + } + } + @Override public void contextDestroyed(ServletContextEvent sce) { if (!isShiroEEDisabled(sce.getServletContext())) { diff --git a/support/jakarta-ee/src/main/resources/META-INF/resources/shiro/formDataNotice.xhtml b/support/jakarta-ee/src/main/resources/META-INF/resources/shiro/formDataNotice.xhtml new file mode 100644 index 0000000000..239ee5752a --- /dev/null +++ b/support/jakarta-ee/src/main/resources/META-INF/resources/shiro/formDataNotice.xhtml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + diff --git a/support/jakarta-ee/src/main/resources/META-INF/shiro-faces.taglib.xml b/support/jakarta-ee/src/main/resources/META-INF/shiro-faces.taglib.xml index 5accc9d1c6..1c35d93795 100644 --- a/support/jakarta-ee/src/main/resources/META-INF/shiro-faces.taglib.xml +++ b/support/jakarta-ee/src/main/resources/META-INF/shiro-faces.taglib.xml @@ -134,4 +134,26 @@ + + + Login-page notice that the user's form data was saved and will be submitted after sign-in, + with a checkbox to discard it instead. Renders nothing when no form data is saved. + Place inside the login form. + + formDataNotice + + shiro/formDataNotice.xhtml + + + Notice text + message + false + + + Label of the discard checkbox + discardLabel + false + + + 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 new file mode 100644 index 0000000000..25b7945e36 --- /dev/null +++ b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitResponseTest.java @@ -0,0 +1,134 @@ +/* + * 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.io.IOException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; + +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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Replayed responses are captured and only applied to the browser on demand + */ +@ExtendWith(MockitoExtension.class) +class FormResubmitResponseTest { + @Mock + private HttpServletResponse browser; + private FormResubmitResponse response; + + @BeforeEach + void setUp() { + response = new FormResubmitResponse(browser); + } + + @Test + void nothingReachesTheBrowserUntilApplied() throws IOException { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + response.setHeader("Cache-Control", "no-store"); + response.addHeader("Set-Cookie", "a=b"); + response.setIntHeader("X-Int", 1); + response.addDateHeader("Expires", 0); + response.addCookie(new Cookie("c", "d")); + response.sendRedirect("/elsewhere"); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "boom"); + response.setContentLength(5); + response.flushBuffer(); + response.resetBuffer(); + response.reset(); + // reading the buffer size is harmless + verify(browser, atLeast(0)).getBufferSize(); + verifyNoMoreInteractions(browser); + } + + @Test + void appliesOperationsInOrder() { + var cookie = new Cookie("c", "d"); + response.setHeader("X", "1"); + response.addHeader("X", "2"); + response.setIntHeader("N", 3); + response.setDateHeader("D", 4L); + response.addCookie(cookie); + response.applyTo(browser); + var order = inOrder(browser); + order.verify(browser).setHeader("X", "1"); + order.verify(browser).addHeader("X", "2"); + order.verify(browser).setIntHeader("N", 3); + order.verify(browser).setDateHeader("D", 4L); + order.verify(browser).addCookie(cookie); + } + + @Test + void redirectOverloadsAreCaptured() throws IOException { + response.sendRedirect("/a"); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FOUND); + response.sendRedirect("/b", HttpServletResponse.SC_SEE_OTHER); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_SEE_OTHER); + response.sendRedirect("/c", false); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FOUND); + response.sendRedirect("/d", HttpServletResponse.SC_MOVED_PERMANENTLY, false); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_MOVED_PERMANENTLY); + response.applyTo(browser); + verify(browser).setHeader("Location", "/a"); + verify(browser).setHeader("Location", "/b"); + verify(browser).setHeader("Location", "/c"); + verify(browser).setHeader("Location", "/d"); + verify(browser, never()).sendRedirect(anyString()); + verify(browser, never()).sendRedirect(anyString(), anyInt(), any(Boolean.class)); + } + + @Test + void errorsOnlyCaptureStatus() throws IOException { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_NOT_FOUND); + response.sendError(HttpServletResponse.SC_FORBIDDEN, "nope"); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN); + verify(browser, never()).sendError(anyInt()); + verify(browser, never()).sendError(anyInt(), anyString()); + } + + @Test + void resetDiscardsEverythingCaptured() throws IOException { + when(browser.getCharacterEncoding()).thenReturn("UTF-8"); + when(browser.getBufferSize()).thenReturn((int) Short.MAX_VALUE); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.setHeader("X", "1"); + response.addCookie(new Cookie("c", "d")); + response.getWriter().print("body"); + response.reset(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + assertThat(response.getBufferAsString()).isEmpty(); + response.applyTo(browser); + verify(browser, never()).setHeader(anyString(), anyString()); + verify(browser, never()).addCookie(any()); + verify(browser, never()).reset(); + } +} diff --git a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitSupportCookiesTest.java b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitSupportCookiesTest.java new file mode 100644 index 0000000000..bfd0929961 --- /dev/null +++ b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitSupportCookiesTest.java @@ -0,0 +1,118 @@ +/* + * 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.ServletContext; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; + +import static org.apache.shiro.ee.filters.FormResubmitSupport.SHIRO_FORM_DATA_KEY; +import static org.apache.shiro.ee.filters.FormResubmitSupportCookies.addCookie; +import static org.apache.shiro.ee.filters.FormResubmitSupportCookies.cookieName; +import static org.apache.shiro.ee.filters.FormResubmitSupportCookies.deleteCookie; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.apache.shiro.web.util.WebUtils; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Secure saved-form cookies can't be planted by another host, since they are host-prefixed + */ +@ExtendWith(MockitoExtension.class) +class FormResubmitSupportCookiesTest { + private static final String SECURE_COOKIES = "org.apache.shiro.form-resubmit.secure-cookies"; + @Mock + private ServletContext servletContext; + @Mock + private HttpServletResponse response; + + private Cookie added(boolean secure, String contextPath, int maxAge) { + when(servletContext.getAttribute(SECURE_COOKIES)).thenReturn(secure); + when(servletContext.getContextPath()).thenReturn(contextPath); + if (maxAge == 0) { + deleteCookie(response, servletContext, SHIRO_FORM_DATA_KEY, true); + } else { + addCookie(response, servletContext, SHIRO_FORM_DATA_KEY, "value", maxAge, true); + } + var cookie = ArgumentCaptor.forClass(Cookie.class); + verify(response).addCookie(cookie.capture()); + return cookie.getValue(); + } + + @Test + void secureCookieIsHostPrefixedAndRootScopedWithContextInName() { + var cookie = added(true, "/my-app", 1); + assertThat(cookie.getName()).isEqualTo("__Host-" + SHIRO_FORM_DATA_KEY + "%2Fmy-app"); + assertThat(cookie.getPath()).isEqualTo("/"); + assertThat(cookie.getSecure()).isTrue(); + assertThat(cookie.isHttpOnly()).isTrue(); + assertThat(cookie.getDomain()).isNull(); + assertThat(cookie.getMaxAge()).isEqualTo(1); + } + + @Test + void secureRootContextNeedsNoSuffix() { + assertThat(added(true, "", 1).getName()).isEqualTo("__Host-" + SHIRO_FORM_DATA_KEY); + } + + @Test + void insecureCookieKeepsLegacyNameAndContextPath() { + var cookie = added(false, "/my-app", 1); + assertThat(cookie.getName()).isEqualTo(SHIRO_FORM_DATA_KEY); + assertThat(cookie.getPath()).isEqualTo("/my-app"); + assertThat(cookie.getSecure()).isFalse(); + assertThat(cookie.isHttpOnly()).isTrue(); + } + + @Test + void deletionMatchesTheCookieItDeletes() { + var cookie = added(true, "/my-app", 0); + assertThat(cookie.getName()).isEqualTo(cookieName(servletContext, SHIRO_FORM_DATA_KEY)); + assertThat(cookie.getPath()).isEqualTo("/"); + assertThat(cookie.getSecure()).isTrue(); + assertThat(cookie.getMaxAge()).isZero(); + } + + @Test + void plainCookieDeletionDoesNotUseTheSecureConvention() { + when(servletContext.getContextPath()).thenReturn("/my-app"); + deleteCookie(response, servletContext, WebUtils.SAVED_REQUEST_KEY, false); + var cookies = ArgumentCaptor.forClass(Cookie.class); + verify(response).addCookie(cookies.capture()); + var cookie = cookies.getValue(); + assertThat(cookie.getName()).isEqualTo(WebUtils.SAVED_REQUEST_KEY); + assertThat(cookie.getPath()).isEqualTo("/my-app"); + assertThat(cookie.getSecure()).isFalse(); + assertThat(cookie.getMaxAge()).isZero(); + } + + @Test + void distinctContextPathsNeverCollide() { + when(servletContext.getAttribute(anyString())).thenReturn(true); + when(servletContext.getContextPath()).thenReturn("/a/b", "/a.b", "/a_b", "/a%2Fb"); + assertThat(cookieName(servletContext, "k")).isEqualTo("__Host-k%2Fa%2Fb"); + assertThat(cookieName(servletContext, "k")).isEqualTo("__Host-k%2Fa.b"); + assertThat(cookieName(servletContext, "k")).isEqualTo("__Host-k%2Fa_b"); + assertThat(cookieName(servletContext, "k")).isEqualTo("__Host-k%2Fa%252Fb"); + } +} 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..a7d458681a 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,22 @@ */ 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.AjaxReplay.FULL_PAGE; +import static org.apache.shiro.ee.filters.FormResubmitSupport.AjaxReplay.PASS_THROUGH; +import static org.apache.shiro.ee.filters.FormResubmitSupport.AjaxReplay.RENDER_ALL; import static org.apache.shiro.ee.filters.FormResubmitSupport.extractJSFNewViewState; +import static org.apache.shiro.ee.filters.FormResubmitSupport.isPartialResponseError; +import static org.apache.shiro.ee.filters.FormResubmitSupport.getDispatchPath; +import static org.apache.shiro.ee.filters.FormResubmitSupport.getFormCharset; 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.ServletContext; import jakarta.servlet.http.HttpServletRequest; import static org.assertj.core.api.Assertions.assertThat; @@ -43,7 +41,6 @@ import static org.mockito.Mockito.when; import org.mockito.junit.jupiter.MockitoExtension; -import org.apache.shiro.mgt.DefaultSecurityManager; /** * Resubmit forms support @@ -51,13 +48,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() { @@ -203,85 +195,200 @@ void doubleSlashPathWithinContextIsRejected() { assertThat(getReferer(request)).isNull(); } + @Test + void dispatchPathStripsContextAndKeepsQuery() { + when(request.getContextPath()).thenReturn("/myapp"); + assertThat(getDispatchPath("/myapp", request)).isEqualTo("/"); + assertThat(getDispatchPath("/myapp?a=1", request)).isEqualTo("/?a=1"); + assertThat(getDispatchPath("/myapp/calf%C3%A9/view.xhtml?a=1&b=%2F", request)) + .isEqualTo("/calf%C3%A9/view.xhtml?a=1&b=%2F"); + assertThat(getDispatchPath("/myapp/WEB-INFO/view.xhtml", request)).isEqualTo("/WEB-INFO/view.xhtml"); + } + + @Test + void dispatchPathDropsPathParameters() { + when(request.getContextPath()).thenReturn("/myapp"); + assertThat(getDispatchPath("/myapp/view.xhtml;jsessionid=123?a=1", request)).isEqualTo("/view.xhtml?a=1"); + assertThat(getDispatchPath("/myapp/a;x/view.xhtml;y", request)).isEqualTo("/a/view.xhtml"); + } + + @Test + void dispatchPathRejectsProtectedDirectories() { + when(request.getContextPath()).thenReturn("/myapp"); + assertThat(getDispatchPath("/myapp/WEB-INF", request)).isNull(); + assertThat(getDispatchPath("/myapp/WEB-INF/web.xml", request)).isNull(); + assertThat(getDispatchPath("/myapp/web-inf/web.xml", request)).isNull(); + assertThat(getDispatchPath("/myapp/META-INF/x", request)).isNull(); + assertThat(getDispatchPath("/myapp/WEB-INF;x/web.xml", request)).isNull(); + assertThat(getDispatchPath("/myapp/;x/WEB-INF/web.xml", request)).isNull(); + assertThat(getDispatchPath("/myapp/%57EB-INF/web.xml", request)).isNull(); + assertThat(getDispatchPath("/myapp/WEB-INF%2Fweb.xml", request)).isNull(); + } + + @Test + void dispatchPathRejectsTraversal() { + when(request.getContextPath()).thenReturn("/myapp"); + assertThat(getDispatchPath("/myapp/a/..;/WEB-INF/web.xml", request)).isNull(); + assertThat(getDispatchPath("/myapp/a/..", request)).isEqualTo("/a/.."); + assertThat(getDispatchPath("/myapp/..", request)).isNull(); + assertThat(getDispatchPath("/myapp/view.xhtml/", request)).isEqualTo("/view.xhtml/"); + assertThat(getDispatchPath("/myapp/a/../WEB-INF/web.xml", request)).isNull(); + assertThat(getDispatchPath("/myapp/a/%2e%2e/WEB-INF/web.xml", request)).isNull(); + assertThat(getDispatchPath("/myapp/a/%2e%2e%2fWEB-INF/web.xml", request)).isNull(); + assertThat(getDispatchPath("/myapp//WEB-INF/web.xml", request)).isNull(); + assertThat(getDispatchPath("/myapp/./WEB-INF/web.xml", request)).isNull(); + assertThat(getDispatchPath("/myapp/a%5C..%5CWEB-INF/web.xml", request)).isNull(); + assertThat(getDispatchPath("/myapp/../../etc/passwd", request)).isNull(); + } + @Test void viewStatePattern() { String statefulFormData - = "j_idt5%3Dj_idt5%26j_idt5%3Aj_idt7%3Daaa%26j_idt5%3Aj_idt9%3Dbbb%26j_idt5%3A" - + "j_idt11%3DSubmit+...%26jakarta.faces.ViewState" - + "%3D-8335355445345003673%3A-6008443334776649058"; - assertThat(isJSFStatefulForm(decode(statefulFormData))).isTrue(); + = "j_idt5=j_idt5&j_idt5%3Aj_idt7=aaa&j_idt5%3Aj_idt9=bbb&j_idt5%3A" + + "j_idt11=Submit+...&jakarta.faces.ViewState" + + "=-8335355445345003673%3A-6008443334776649058"; + assertThat(isJSFStatefulForm(parseFormData(statefulFormData))).isTrue(); String statelessFormData - = "j_idt5%3Dj_idt5%26j_idt5%3Aj_idt7%3Daaa%26j_idt5%3Aj_idt9%3Dbbb%26j_idt5%3A" - + "j_idt11%3DSubmit+...%26jakarta.faces.ViewState%3Dstateless"; - assertThat(isJSFStatefulForm(statelessFormData)).isFalse(); + = "j_idt5=j_idt5&j_idt5%3Aj_idt7=aaa&j_idt5%3Aj_idt9=bbb&j_idt5%3A" + + "j_idt11=Submit+...&jakarta.faces.ViewState=stateless"; + assertThat(isJSFStatefulForm(parseFormData(statelessFormData))).isFalse(); assertThatExceptionOfType(NullPointerException.class).isThrownBy(() -> isJSFStatefulForm(null)); String nonJSFFormData - = "j_idt5%3Dj_idt5%26j_idt5%3Aj_idt7%3Daaa%26j_idt5%3Aj_idt9%3Dbbb%26j_idt5%3A" - + "j_idt11%3DSubmit+..."; - assertThat(isJSFStatefulForm("xxx")).isFalse(); - assertThat(isJSFStatefulForm(nonJSFFormData)).isFalse(); + = "j_idt5=j_idt5&j_idt5%3Aj_idt7=aaa&j_idt5%3Aj_idt9=bbb&j_idt5%3A" + + "j_idt11=Submit+..."; + assertThat(isJSFStatefulForm(parseFormData("xxx"))).isFalse(); + assertThat(isJSFStatefulForm(parseFormData(nonJSFFormData))).isFalse(); + } + + @Test + void parseFormDataKeepsEmptyFieldsAndDecodesOnce() { + assertThat(parseFormData("")).isEmpty(); + assertThat(parseFormData("text=a%26b%2Bc%3Dd&empty=&flag&multi=1&multi=2&multi=%3D")) + .containsExactly( + Map.entry("text", List.of("a&b+c=d")), + Map.entry("empty", List.of("")), + Map.entry("flag", List.of("")), + Map.entry("multi", List.of("1", "2", "="))); + } + + @Test + void parseFormDataHonoursCharset() { + assertThat(FormResubmitSupport.parseFormData("name=J%F6rg", StandardCharsets.ISO_8859_1)) + .containsExactly(Map.entry("name", List.of("J\u00f6rg"))); + assertThat(FormResubmitSupport.parseFormData("name=J%C3%B6rg", StandardCharsets.UTF_8)) + .containsExactly(Map.entry("name", List.of("J\u00f6rg"))); + } + + @Test + void formCharsetFallsBackFromRequestToContextToUtf8(@Mock ServletContext servletContext) { + when(request.getCharacterEncoding()).thenReturn("ISO-8859-1"); + assertThat(getFormCharset(request, servletContext)).isEqualTo(StandardCharsets.ISO_8859_1); + + when(request.getCharacterEncoding()).thenReturn(null); + when(servletContext.getRequestCharacterEncoding()).thenReturn("US-ASCII"); + assertThat(getFormCharset(request, servletContext)).isEqualTo(StandardCharsets.US_ASCII); + + when(servletContext.getRequestCharacterEncoding()).thenReturn("no-such-charset"); + assertThat(getFormCharset(request, servletContext)).isEqualTo(StandardCharsets.UTF_8); + + when(servletContext.getRequestCharacterEncoding()).thenReturn(null); + assertThat(getFormCharset(request, servletContext)).isEqualTo(StandardCharsets.UTF_8); } @Test void extractViewState() { - assertThatExceptionOfType(NullPointerException.class).isThrownBy(() -> extractJSFNewViewState(null, null)); - assertThat(extractJSFNewViewState("", "hello")).isEqualTo("hello"); - assertThat(extractJSFNewViewState("xxx", "jakarta.faces.ViewState=stateless&hello=bye")) - .isEqualTo("jakarta.faces.ViewState=stateless&hello=bye"); - assertThat(extractJSFNewViewState("", - "jakarta.faces.ViewState=stateless&hello=bye")) - .isEqualTo("jakarta.faces.ViewState=stateless&hello=bye"); - assertThat(extractJSFNewViewState("", - "aaa=bbb&jakarta.faces.ViewState=xxx:yyy&hello=bye")) - .isEqualTo("aaa=bbb&jakarta.faces.ViewState=xxx:yyy&hello=bye"); - assertThat(extractJSFNewViewState("", - "jakarta.faces.ViewState=987:654&hello=bye")) - .isEqualTo("jakarta.faces.ViewState=123:456&hello=bye"); - assertThat(extractJSFNewViewState("", - "jakarta.faces.ViewState=987:654&hello=bye")) - .isEqualTo("jakarta.faces.ViewState=-123:-456&hello=bye"); - assertThat(extractJSFNewViewState("", - "jakarta.faces.ViewState=-987:-654&hello=bye")) - .isEqualTo("jakarta.faces.ViewState=-123:-456&hello=bye"); - assertThat(extractJSFNewViewState("", - "aaa=bbb&jakarta.faces.ViewState=-987:-654&hello=bye")) - .isEqualTo("aaa=bbb&jakarta.faces.ViewState=-123:-456&hello=bye"); - assertThat(extractJSFNewViewState("", - "aaa=bbb&jakarta.faces.ViewState=-987:-654")) - .isEqualTo("aaa=bbb&jakarta.faces.ViewState=-123:-456"); + assertThatExceptionOfType(NullPointerException.class).isThrownBy(() -> extractJSFNewViewState(null)); + assertThat(extractJSFNewViewState("")).isNull(); + assertThat(extractJSFNewViewState("xxx")).isNull(); + assertThat(extractJSFNewViewState("")) + .isEqualTo("123:456"); + assertThat(extractJSFNewViewState("" + + "")) + .isEqualTo("-123:-456"); } @Test void noAjaxRequests() { - assertThat(noJSFAjaxRequests("aaa=bbb&jakarta.faces.ViewState=-123:-456" - + "&jakarta.faces.partial.ajax=true&hello=bye", false)).isEqualTo(new PartialAjaxResult( - "aaa=bbb&jakarta.faces.ViewState=-123:-456&hello=bye", - true, false)); - assertThat(noJSFAjaxRequests("j_idt12=j_idt12&j_idt12:j_idt14=asdf&j_idt12:j_idt16=asdf" + assertThat(noJSFAjaxRequests(parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456" + + "&jakarta.faces.partial.ajax=true&hello=bye"), false)).isEqualTo(new PartialAjaxResult( + parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456&hello=bye"), + true, FULL_PAGE)); + assertThat(noJSFAjaxRequests(parseFormData("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" + "&jakarta.faces.partial.event=click" + "&jakarta.faces.partial.execute=j_idt12:j_idt18 j_idt12" + "&jakarta.faces.partial.render=j_idt12" + "&jakarta.faces.behavior.event=action" - + "&jakarta.faces.partial.ajax=false", false)) - .isEqualTo(new PartialAjaxResult("j_idt12=j_idt12&j_idt12:j_idt14=asdf&j_idt12:j_idt16=asdf" - + "&jakarta.faces.ViewState=7709788254588873136:-8052771455757429917&j_idt12:j_idt18=", - true, false)); + + "&jakarta.faces.partial.ajax=false"), false)) + .isEqualTo(new PartialAjaxResult(parseFormData("j_idt12=j_idt12&j_idt12:j_idt14=asdf" + + "&j_idt12:j_idt16=asdf" + + "&jakarta.faces.ViewState=7709788254588873136:-8052771455757429917&j_idt12:j_idt18="), + true, FULL_PAGE)); + } + + @Test + void ajaxFieldsAreKeptForPassThrough() { + String ajaxForm = "aaa=bbb&jakarta.faces.ViewState=-123:-456&jakarta.faces.source=j_idt12:j_idt18" + + "&jakarta.faces.partial.render=j_idt12&jakarta.faces.partial.ajax=true"; + assertThat(noJSFAjaxRequests(parseFormData(ajaxForm), false, PASS_THROUGH)).isEqualTo(new PartialAjaxResult( + parseFormData(ajaxForm + "&j_idt12:j_idt18="), true, PASS_THROUGH)); + // a non-Ajax form submitted while the Ajax client is on the page isn't an Ajax replay + assertThat(noJSFAjaxRequests(parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456"), false, RENDER_ALL)) + .isEqualTo(new PartialAjaxResult(parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456"), + false, FULL_PAGE)); + } + + @Test + void partialResponseErrorIsDetected() { + assertThat(isPartialResponseError(""" + + + + class jakarta.faces.application.ViewExpiredException + + + """)).isTrue(); + assertThat(isPartialResponseError(""" + + + + not an error]]> + + + """)).isFalse(); + assertThat(isPartialResponseError("full page")).isFalse(); } @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 ajaxReplayOfRebuiltViewRendersAll() { + String ajaxForm = "aaa=bbb&jakarta.faces.ViewState=-123:-456&jakarta.faces.source=j_idt12:j_idt18" + + "&jakarta.faces.partial.render=j_idt12&jakarta.faces.partial.ajax=true"; + assertThat(noJSFAjaxRequests(parseFormData(ajaxForm), false, RENDER_ALL).result()) + .containsEntry("jakarta.faces.partial.render", List.of("@all")) + .containsEntry("jakarta.faces.partial.ajax", List.of("true")); + // the render targets stay in place when a stateless view isn't rebuilt + assertThat(noJSFAjaxRequests(parseFormData(ajaxForm), true, RENDER_ALL)) + .isEqualTo(new PartialAjaxResult(parseFormData(ajaxForm + "&j_idt12:j_idt18="), true, PASS_THROUGH)); + // or without any render targets + assertThat(noJSFAjaxRequests(parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456" + + "&jakarta.faces.source=j_idt12:j_idt18&jakarta.faces.partial.ajax=true"), false, RENDER_ALL) + .result()).containsEntry("jakarta.faces.partial.render", List.of("@all")); + } + + @Test + void encodedAjaxFieldsAreRemovedCompletely() { + var result = noJSFAjaxRequests(parseFormData("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( + parseFormData("text=a%26b%2Bc%3Dd&jakarta.faces.ViewState=123%3A456&j_idt12%3Aj_idt18="), + true, FULL_PAGE)); + assertThat(result.result()).containsEntry("text", List.of("a&b+c=d")); } @Test @@ -306,7 +413,7 @@ void clientSideStateSavingNoAjax() { &jakarta.faces.partial.execute=secondForm:submitSecond secondForm &jakarta.faces.partial.render=secondForm&jakarta.faces.behavior.event=action &jakarta.faces.partial.ajax=true""".replace("\n", ""); - assertThat(noJSFAjaxRequests(savedRequest, true).result).isEqualTo(""" + assertThat(noJSFAjaxRequests(parseFormData(savedRequest), true).result()).isEqualTo(parseFormData(""" secondForm=secondForm&secondForm:address=asfd&secondForm:city=asdf &jakarta.faces.ViewState=5BDAqkysYaMvzcnTG3bVSXRoK43OvdMyb8w6RicBatqzOdHBwl/cFvOXYfYCwvJoBU6/qv 735kadAP67luQ/wMqF4jAQyBKDdxy5F4CxNz4FhAYC2iCd613QnwLWP8BX3so7BylQxIN2Y64n6LUogwkgZLEAHgTBDQGwG @@ -325,74 +432,10 @@ void clientSideStateSavingNoAjax() { &jakarta.faces.partial.event=click &jakarta.faces.partial.execute=secondForm:submitSecond secondForm &jakarta.faces.partial.render=secondForm&jakarta.faces.behavior.event=action - &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(); + &jakarta.faces.partial.ajax=true&secondForm:submitSecond=""".replace("\n", ""))); } - private static String decode(String plain) { - return URLDecoder.decode(plain, StandardCharsets.UTF_8); + private static Map> parseFormData(String formData) { + return FormResubmitSupport.parseFormData(formData, StandardCharsets.UTF_8); } } diff --git a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/SavedFormDataTest.java b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/SavedFormDataTest.java new file mode 100644 index 0000000000..3f30c47206 --- /dev/null +++ b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/SavedFormDataTest.java @@ -0,0 +1,131 @@ +/* + * 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 org.apache.shiro.ee.filters.FormResubmitSupport.FORM_DATA_CACHE; +import static org.apache.shiro.ee.filters.FormResubmitSupport.SHIRO_FORM_DATA_KEY; +import static org.apache.shiro.ee.filters.FormResubmitSupport.getSavedFormDataKey; +import static org.apache.shiro.ee.filters.FormResubmitSupport.hasSavedFormData; +import static org.apache.shiro.ee.filters.FormResubmitSupport.isFormDataDiscarded; +import static org.apache.shiro.ee.filters.Forms.DISCARD_FORM_DATA_PARAMETER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import java.util.UUID; +import jakarta.servlet.ServletContext; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.shiro.cache.MemoryConstrainedCacheManager; +import org.apache.shiro.mgt.DefaultSecurityManager; +import org.apache.shiro.util.ThreadContext; +import org.apache.shiro.web.mgt.CookieRememberMeManager; +import org.apache.shiro.web.util.WebUtils; +import org.junit.jupiter.api.AfterEach; +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.ArgumentCaptor; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +/** + * Login-page view of saved form data: whether any is waiting, and the user's choice to discard it + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class SavedFormDataTest { + @Mock + private HttpServletRequest request; + @Mock + private ServletContext servletContext; + @Mock + private HttpServletResponse response; + private final DefaultSecurityManager securityManager = new DefaultSecurityManager(); + + @BeforeEach + void bindSecurityManager() { + when(request.getServletContext()).thenReturn(servletContext); + securityManager.setCacheManager(new MemoryConstrainedCacheManager()); + ThreadContext.bind(securityManager); + } + + @AfterEach + void unbindSecurityManager() { + ThreadContext.unbindSecurityManager(); + } + + private void savedFormDataCookie(String value) { + when(request.getCookies()).thenReturn(new Cookie[] {new Cookie(SHIRO_FORM_DATA_KEY, value)}); + } + + @Test + void nothingSavedWithoutCookie() { + assertThat(getSavedFormDataKey(request)).isNull(); + assertThat(hasSavedFormData(request)).isFalse(); + } + + @Test + void malformedCookieIsIgnored() { + savedFormDataCookie("not-a-uuid"); + assertThat(getSavedFormDataKey(request)).isNull(); + assertThat(hasSavedFormData(request)).isFalse(); + } + + @Test + void savedOnlyWhileFormDataIsCached() { + var key = UUID.randomUUID(); + savedFormDataCookie(key.toString()); + assertThat(getSavedFormDataKey(request)).isEqualTo(key); + assertThat(hasSavedFormData(request)).as("stale cookie").isFalse(); + securityManager.getCacheManager().getCache(FORM_DATA_CACHE).put(key, "firstName=Jack"); + assertThat(hasSavedFormData(request)).isTrue(); + } + + @Test + void discardOnlyWhenAsked() { + assertThat(isFormDataDiscarded(request)).as("no checkbox").isFalse(); + when(request.getParameter(DISCARD_FORM_DATA_PARAMETER)).thenReturn("on"); + assertThat(isFormDataDiscarded(request)).as("checked checkbox").isTrue(); + when(request.getParameter(DISCARD_FORM_DATA_PARAMETER)).thenReturn("false"); + assertThat(isFormDataDiscarded(request)).isFalse(); + } + + @Test + void savedRequestDeletionMatchesThePlainCookieEvenWithSecureFormCookies() throws Exception { + securityManager.setRememberMeManager(new CookieRememberMeManager()); + when(servletContext.getAttribute("org.apache.shiro.form-resubmit.secure-cookies")).thenReturn(Boolean.TRUE); + when(servletContext.getContextPath()).thenReturn("/myapp"); + when(servletContext.getSessionTimeout()).thenReturn(1); + when(request.getContextPath()).thenReturn("/myapp"); + when(request.getRequestURI()).thenReturn("/myapp/form"); + FormResubmitSupport.saveRequest(request, response, false); + var cookies = ArgumentCaptor.forClass(Cookie.class); + verify(response).addCookie(cookies.capture()); + Cookie saved = cookies.getValue(); + when(request.getCookies()).thenReturn(new Cookie[] {saved}); + FormResubmitSupport.redirectToSaved(request, response, (path, req) -> false, "/", false); + verify(response, times(2)).addCookie(cookies.capture()); + Cookie deleted = cookies.getValue(); + assertThat(saved.getName()).isEqualTo(WebUtils.SAVED_REQUEST_KEY); + assertThat(saved.getMaxAge()).isPositive(); + assertThat(deleted.getName()).isEqualTo(saved.getName()); + assertThat(deleted.getPath()).isEqualTo(saved.getPath()).isEqualTo("/myapp"); + assertThat(deleted.getMaxAge()).isZero(); + verify(response).sendRedirect("/myapp/form"); + } +}