diff --git a/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc b/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc index f7cd752328..e26d77b866 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc @@ -125,7 +125,7 @@ If used, the application's base URL, such as `https://app.example.org`, replaces [NOTE] ==== By default, `OidcClientInitiatedLogoutSuccessHandler` redirects to the logout URL using a standard HTTP redirect with the `GET` method. -To perform the logout using a `POST` request, set the redirect strategy to `FormRedirectStrategy`, for example with `OidcClientInitiatedLogoutSuccessHandler.setRedirectStrategy(new FormRedirectStrategy())`. +To perform the logout using a `POST` request, set the redirect strategy to `FormPostRedirectStrategy`, for example with `OidcClientInitiatedLogoutSuccessHandler.setRedirectStrategy(new FormPostRedirectStrategy())`. ==== [[configure-provider-initiated-oidc-logout]] diff --git a/web/src/main/java/org/springframework/security/web/FormPostRedirectStrategy.java b/web/src/main/java/org/springframework/security/web/FormPostRedirectStrategy.java new file mode 100644 index 0000000000..6445c078f6 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/FormPostRedirectStrategy.java @@ -0,0 +1,116 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * 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 + * + * https://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.springframework.security.web; + +import java.io.IOException; +import java.util.Base64; +import java.util.List; +import java.util.Map.Entry; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.keygen.Base64StringKeyGenerator; +import org.springframework.security.crypto.keygen.StringKeyGenerator; +import org.springframework.web.util.HtmlUtils; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Redirect using an auto-submitting HTML form using the POST method. All query params + * provided in the URL are changed to inputs in the form so they are submitted as POST + * data instead of query string data. + * + * @author Craig Andrews + * @author Steve Riesenberg + * @since 6.5 + */ +public final class FormPostRedirectStrategy implements RedirectStrategy { + + private static final String CONTENT_SECURITY_POLICY_HEADER = "Content-Security-Policy"; + + private static final String REDIRECT_PAGE_TEMPLATE = """ + + + + + + + + Redirect + + +
+ {{params}} + +
+ + + + """; + + private static final String HIDDEN_INPUT_TEMPLATE = """ + + """; + + private static final StringKeyGenerator DEFAULT_NONCE_GENERATOR = new Base64StringKeyGenerator( + Base64.getUrlEncoder().withoutPadding(), 96); + + @Override + public void sendRedirect(final HttpServletRequest request, final HttpServletResponse response, final String url) + throws IOException { + final UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(url); + + final StringBuilder hiddenInputsHtmlBuilder = new StringBuilder(); + for (final Entry> entry : uriComponentsBuilder.build().getQueryParams().entrySet()) { + final String name = entry.getKey(); + for (final String value : entry.getValue()) { + // @formatter:off + final String hiddenInput = HIDDEN_INPUT_TEMPLATE + .replace("{{name}}", HtmlUtils.htmlEscape(name)) + .replace("{{value}}", HtmlUtils.htmlEscape(value)); + // @formatter:on + hiddenInputsHtmlBuilder.append(hiddenInput.trim()); + } + } + + // Create the script-src policy directive for the Content-Security-Policy header + final String nonce = DEFAULT_NONCE_GENERATOR.generateKey(); + final String policyDirective = "script-src 'nonce-%s'".formatted(nonce); + + // @formatter:off + final String html = REDIRECT_PAGE_TEMPLATE + // Clear the query string as we don't want that to be part of the form action URL + .replace("{{action}}", HtmlUtils.htmlEscape(uriComponentsBuilder.query(null).build().toUriString())) + .replace("{{params}}", hiddenInputsHtmlBuilder.toString()) + .replace("{{nonce}}", HtmlUtils.htmlEscape(nonce)); + // @formatter:on + + response.setStatus(HttpStatus.OK.value()); + response.setContentType(MediaType.TEXT_HTML_VALUE); + response.setHeader(CONTENT_SECURITY_POLICY_HEADER, policyDirective); + response.getWriter().write(html); + response.getWriter().flush(); + } + +} diff --git a/web/src/main/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHints.java b/web/src/main/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHints.java index 54c4493064..86df3618f2 100644 --- a/web/src/main/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHints.java +++ b/web/src/main/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHints.java @@ -54,11 +54,6 @@ class WebMvcSecurityRuntimeHints implements RuntimeHintsRegistrar { hints.resources().registerResource(webauthnJavascript); } - ClassPathResource redirect = new ClassPathResource("org/springframework/security/form-redirect.js"); - if (redirect.exists()) { - hints.resources().registerResource(redirect); - } - } } diff --git a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilter.java b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilter.java index 2b92717502..c2c80f19bd 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilter.java +++ b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilter.java @@ -111,20 +111,4 @@ public final class DefaultResourcesFilter extends GenericFilterBean { new MediaType("text", "javascript", StandardCharsets.UTF_8)); } - /** - * Create an instance of {@link DefaultResourcesFilter} serving Spring Security's - * default webauthn javascript. - *

- * The created {@link DefaultResourcesFilter} matches requests - * {@code HTTP GET /form-redirect.js}, and returns the default webauthn javascript at - * {@code org/springframework/security/form-redirect.js} with content-type - * {@code text/javascript;charset=UTF-8}. - * @return - - */ - public static DefaultResourcesFilter formRedirectJavascript() { - return new DefaultResourcesFilter(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/form-redirect.js"), - new ClassPathResource("org/springframework/security/form-redirect.js"), - new MediaType("text", "javascript", StandardCharsets.UTF_8)); - } - } diff --git a/web/src/main/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilter.java b/web/src/main/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilter.java index a147e1b301..7f96e6f0d9 100644 --- a/web/src/main/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilter.java +++ b/web/src/main/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilter.java @@ -98,21 +98,4 @@ public final class DefaultResourcesWebFilter implements WebFilter { new MediaType("text", "css", StandardCharsets.UTF_8)); } - /** - * Create an instance of {@link DefaultResourcesWebFilter} serving Spring Security's - * form redirect javascript. - *

- * The created {@link DefaultResourcesFilter} matches requests - * {@code HTTP GET /form-redirect.js}, and returns the default javascript at - * {@code org/springframework/security/form-redirect.js} with content-type - * {@code text/javascript;charset=UTF-8}. - * @return - - */ - public static DefaultResourcesWebFilter formRedirectJavascript() { - return new DefaultResourcesWebFilter( - new PathPatternParserServerWebExchangeMatcher("/form-redirect.js", HttpMethod.GET), - new ClassPathResource("org/springframework/security/form-redirect.js"), - new MediaType("text", "javascript", StandardCharsets.UTF_8)); - } - } diff --git a/web/src/main/java/org/springframework/security/web/server/ui/FormRedirectStrategy.java b/web/src/main/java/org/springframework/security/web/server/ui/FormRedirectStrategy.java deleted file mode 100644 index e2b1f89fe4..0000000000 --- a/web/src/main/java/org/springframework/security/web/server/ui/FormRedirectStrategy.java +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2002-2023 the original author or authors. - * - * 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 - * - * https://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.springframework.security.web.server.ui; - -import java.io.IOException; -import java.util.List; -import java.util.Map.Entry; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.security.web.RedirectStrategy; -import org.springframework.web.util.UriComponentsBuilder; - -/** - * Redirect using an autosubmitting HTML form using the POST method. All query params - * provided in the URL are changed to inputs in the form so they are submitted as POST - * data instead of query string data. - */ -/* default */ class FormRedirectStrategy implements RedirectStrategy { - - private static final String REDIRECT_PAGE_TEMPLATE = """ - - - - - - - - Redirect - - - -

-
- {{params}} - -
-
- - - - """; - - private static final String HIDDEN_INPUT_TEMPLATE = """ - - """; - - @Override - public void sendRedirect(final HttpServletRequest request, final HttpServletResponse response, final String url) - throws IOException { - final UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(url); - - final StringBuilder hiddenInputsHtmlBuilder = new StringBuilder(); - // inputs - for (final Entry> entry : uriComponentsBuilder.build().getQueryParams().entrySet()) { - final String name = entry.getKey(); - for (final String value : entry.getValue()) { - hiddenInputsHtmlBuilder.append(HtmlTemplates.fromTemplate(HIDDEN_INPUT_TEMPLATE) - .withValue("name", name) - .withValue("value", value) - .render()); - } - } - - final String html = HtmlTemplates.fromTemplate(REDIRECT_PAGE_TEMPLATE) - // clear the query string as we don't want that to be part of the form action - // URL - .withValue("action", uriComponentsBuilder.query(null).build().toUriString()) - .withRawHtml("params", hiddenInputsHtmlBuilder.toString()) - .withValue("contextPath", request.getContextPath()) - .render(); - response.setStatus(HttpStatus.OK.value()); - response.setContentType(MediaType.TEXT_HTML_VALUE); - response.getWriter().write(html); - response.getWriter().flush(); - } - -} diff --git a/web/src/main/resources/org/springframework/security/form-redirect.js b/web/src/main/resources/org/springframework/security/form-redirect.js deleted file mode 100644 index aecae36ee0..0000000000 --- a/web/src/main/resources/org/springframework/security/form-redirect.js +++ /dev/null @@ -1 +0,0 @@ -document.getElementById("redirectForm").submit(); diff --git a/web/src/test/java/org/springframework/security/web/server/ui/FormRedirectStrategyTests.java b/web/src/test/java/org/springframework/security/web/FormPostRedirectStrategyTests.java similarity index 68% rename from web/src/test/java/org/springframework/security/web/server/ui/FormRedirectStrategyTests.java rename to web/src/test/java/org/springframework/security/web/FormPostRedirectStrategyTests.java index 4eb7b4c3b6..a3be15cb77 100644 --- a/web/src/test/java/org/springframework/security/web/server/ui/FormRedirectStrategyTests.java +++ b/web/src/test/java/org/springframework/security/web/FormPostRedirectStrategyTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,10 +14,11 @@ * limitations under the License. */ -package org.springframework.security.web.server.ui; +package org.springframework.security.web; import java.io.IOException; +import org.assertj.core.api.ThrowingConsumer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -30,9 +31,11 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import static org.assertj.core.api.Assertions.assertThat; -public class FormRedirectStrategyTests { +public class FormPostRedirectStrategyTests { - private FormRedirectStrategy formRedirectStrategy; + private static final String POLICY_DIRECTIVE_PATTERN = "script-src 'nonce-(.+)'"; + + private FormPostRedirectStrategy redirectStrategy; private MockHttpServletRequest request; @@ -40,7 +43,7 @@ public class FormRedirectStrategyTests { @BeforeEach public void beforeEach() { - this.formRedirectStrategy = new FormRedirectStrategy(); + this.redirectStrategy = new FormPostRedirectStrategy(); final MockServletContext mockServletContext = new MockServletContext(); mockServletContext.setContextPath("/contextPath"); // the request URL doesn't matter @@ -50,39 +53,43 @@ public class FormRedirectStrategyTests { @Test public void absoluteUrlNoParametersRedirect() throws IOException { - this.formRedirectStrategy.sendRedirect(this.request, this.response, "https://example.com"); + this.redirectStrategy.sendRedirect(this.request, this.response, "https://example.com"); assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value()); assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE); assertThat(this.response.getContentAsString()).contains("action=\"https://example.com\""); + assertThat(this.response).satisfies(hasScriptSrcNonce()); } @Test public void rootRelativeUrlNoParametersRedirect() throws IOException { - this.formRedirectStrategy.sendRedirect(this.request, this.response, "/test"); + this.redirectStrategy.sendRedirect(this.request, this.response, "/test"); assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value()); assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE); assertThat(this.response.getContentAsString()).contains("action=\"/test\""); + assertThat(this.response).satisfies(hasScriptSrcNonce()); } @Test public void relativeUrlNoParametersRedirect() throws IOException { - this.formRedirectStrategy.sendRedirect(this.request, this.response, "test"); + this.redirectStrategy.sendRedirect(this.request, this.response, "test"); assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value()); assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE); assertThat(this.response.getContentAsString()).contains("action=\"test\""); + assertThat(this.response).satisfies(hasScriptSrcNonce()); } @Test public void absoluteUrlWithFragmentRedirect() throws IOException { - this.formRedirectStrategy.sendRedirect(this.request, this.response, "https://example.com/path#fragment"); + this.redirectStrategy.sendRedirect(this.request, this.response, "https://example.com/path#fragment"); assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value()); assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE); assertThat(this.response.getContentAsString()).contains("action=\"https://example.com/path#fragment\""); + assertThat(this.response).satisfies(hasScriptSrcNonce()); } @Test public void absoluteUrlWithQueryParamsRedirect() throws IOException { - this.formRedirectStrategy.sendRedirect(this.request, this.response, + this.redirectStrategy.sendRedirect(this.request, this.response, "https://example.com/path?param1=one¶m2=two#fragment"); assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value()); assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE); @@ -91,6 +98,18 @@ public class FormRedirectStrategyTests { .contains(""); assertThat(this.response.getContentAsString()) .contains(""); + assertThat(this.response).satisfies(hasScriptSrcNonce()); + } + + private ThrowingConsumer hasScriptSrcNonce() { + return (response) -> { + final String policyDirective = response.getHeader("Content-Security-Policy"); + assertThat(policyDirective).isNotEmpty(); + assertThat(policyDirective).matches(POLICY_DIRECTIVE_PATTERN); + + final String nonce = policyDirective.replaceFirst(POLICY_DIRECTIVE_PATTERN, "$1"); + assertThat(response.getContentAsString()).contains("