Преглед на файлове

Polish gh-16214

This commit applies the following changes:

* Added local Content-Security-Policy with script-src nonce directive
* Removed form-redirect.js and associated changes
* Renamed to FormPostRedirectStrategy
* Removed HtmlUtils usage
* Moved to same package as DefaultRedirectStrategy
Steve Riesenberg преди 6 месеца
родител
ревизия
54a6a19e05

+ 1 - 1
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]]

+ 116 - 0
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 = """
+			<!DOCTYPE html>
+			<html lang="en">
+			  <head>
+			    <meta charset="utf-8">
+			    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+			    <meta name="description" content="">
+			    <meta name="author" content="">
+			    <title>Redirect</title>
+			  </head>
+			  <body>
+			    <form id="redirect-form" method="POST" action="{{action}}">
+			      {{params}}
+			      <noscript>
+			        <p>JavaScript is not enabled for this page.</p>
+			        <button type="submit">Click to continue</button>
+			      </noscript>
+			    </form>
+			    <script nonce="{{nonce}}">
+			      document.getElementById("redirect-form").submit();
+			    </script>
+			  </body>
+			</html>
+			""";
+
+	private static final String HIDDEN_INPUT_TEMPLATE = """
+			<input name="{{name}}" type="hidden" value="{{value}}" />
+			""";
+
+	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<String, List<String>> 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();
+	}
+
+}

+ 0 - 5
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);
-		}
-
 	}
 
 }

+ 0 - 16
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.
-	 * <p>
-	 * 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));
-	}
-
 }

+ 0 - 17
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.
-	 * <p>
-	 * 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));
-	}
-
 }

+ 0 - 95
web/src/main/java/org/springframework/security/web/server/ui/FormRedirectStrategy.java

@@ -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 = """
-			<!DOCTYPE html>
-			<html lang="en">
-				<head>
-					<meta charset="utf-8">
-					<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
-					<meta name="description" content="">
-					<meta name="author" content="">
-					<title>Redirect</title>
-					<link href="{{contextPath}}/default-ui.css" rel="stylesheet" />
-				</head>
-				<body>
-				<div class="content">
-					<form id="redirectForm" class="redirect-form" method="POST" action="{{action}}">
-						{{params}}
-						<button class="primary" type="submit">Click to Continue</button>
-					</form>
-				</div>
-				<script src="{{contextPath}}/form-redirect.js"></script>
-				</body>
-			</html>
-			""";
-
-	private static final String HIDDEN_INPUT_TEMPLATE = """
-			<input name="{{name}}" type="hidden" value="{{value}}" />
-			""";
-
-	@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<String, List<String>> 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();
-	}
-
-}

+ 0 - 1
web/src/main/resources/org/springframework/security/form-redirect.js

@@ -1 +0,0 @@
-document.getElementById("redirectForm").submit();

+ 29 - 10
web/src/test/java/org/springframework/security/web/server/ui/FormRedirectStrategyTests.java → 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&param2=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("<input name=\"param1\" type=\"hidden\" value=\"one\" />");
 		assertThat(this.response.getContentAsString())
 			.contains("<input name=\"param2\" type=\"hidden\" value=\"two\" />");
+		assertThat(this.response).satisfies(hasScriptSrcNonce());
+	}
+
+	private ThrowingConsumer<MockHttpServletResponse> 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("<script nonce=\"%s\">".formatted(nonce));
+		};
 	}
 
 }

+ 0 - 6
web/src/test/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHintsTests.java

@@ -74,10 +74,4 @@ class WebMvcSecurityRuntimeHintsTests {
 			.forResource("org/springframework/security/spring-security-webauthn.js")).accepts(this.hints);
 	}
 
-	@Test
-	void formRedirectJavascriptHasHints() {
-		assertThat(RuntimeHintsPredicates.resource().forResource("org/springframework/security/form-redirect.js"))
-			.accepts(this.hints);
-	}
-
 }

+ 0 - 31
web/src/test/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilterTests.java

@@ -94,35 +94,4 @@ public class DefaultResourcesFilterTests {
 
 	}
 
-	@Nested
-	class FormRedirectJavascriptFilter {
-
-		private final DefaultResourcesFilter formRedirectJavascriptFilter = DefaultResourcesFilter
-			.formRedirectJavascript();
-
-		private final MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new Object())
-			.addFilters(this.formRedirectJavascriptFilter)
-			.build();
-
-		@Test
-		void doFilterThenRender() throws Exception {
-			this.mockMvc.perform(get("/form-redirect.js"))
-				.andExpect(status().isOk())
-				.andExpect(content().contentType("text/javascript;charset=UTF-8"))
-				.andExpect(content().string(containsString("submit")));
-		}
-
-		@Test
-		void doFilterWhenPathDoesNotMatchThenCallsThrough() throws Exception {
-			this.mockMvc.perform(get("/does-not-match")).andExpect(status().isNotFound());
-		}
-
-		@Test
-		void toStringPrintsPathAndResource() {
-			assertThat(this.formRedirectJavascriptFilter.toString()).isEqualTo(
-					"DefaultResourcesFilter [matcher=Ant [pattern='/form-redirect.js', GET], resource=org/springframework/security/form-redirect.js]");
-		}
-
-	}
-
 }

+ 0 - 77
web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesFormRedirectJavascriptWebFilterTests.java

@@ -1,77 +0,0 @@
-/*
- * Copyright 2002-2024 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.nio.charset.StandardCharsets;
-import java.util.List;
-
-import org.junit.jupiter.api.Test;
-import reactor.core.publisher.Mono;
-
-import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
-import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
-import org.springframework.mock.web.server.MockServerWebExchange;
-import org.springframework.web.server.WebFilterChain;
-import org.springframework.web.server.WebHandler;
-import org.springframework.web.server.handler.DefaultWebFilterChain;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * @author Craig Andrews
- * @since 6.4
- */
-class DefaultResourcesFormRedirectJavascriptWebFilterTests {
-
-	private final WebHandler notFoundHandler = (exchange) -> {
-		exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND);
-		return Mono.empty();
-	};
-
-	private final DefaultResourcesWebFilter filter = DefaultResourcesWebFilter.formRedirectJavascript();
-
-	@Test
-	void filterWhenPathMatchesThenRenders() {
-		MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/form-redirect.js"));
-		WebFilterChain filterChain = new DefaultWebFilterChain(this.notFoundHandler, List.of(this.filter));
-
-		filterChain.filter(exchange).block();
-
-		assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK);
-		assertThat(exchange.getResponse().getHeaders().getContentType())
-			.isEqualTo(new MediaType("text", "javascript", StandardCharsets.UTF_8));
-		assertThat(exchange.getResponse().getBodyAsString().block()).contains("document");
-	}
-
-	@Test
-	void filterWhenPathDoesNotMatchThenCallsThrough() {
-		MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/does-not-match"));
-		WebFilterChain filterChain = new DefaultWebFilterChain(this.notFoundHandler, List.of(this.filter));
-
-		filterChain.filter(exchange).block();
-
-		assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
-	}
-
-	@Test
-	void toStringPrintsPathAndResource() {
-		assertThat(this.filter.toString()).isEqualTo(
-				"DefaultResourcesWebFilter{matcher=PathMatcherServerWebExchangeMatcher{pattern='/form-redirect.js', method=GET}, resource='org/springframework/security/form-redirect.js'}");
-	}
-
-}

+ 1 - 1
web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesCssWebFilterTests.java → web/src/test/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilterTests.java

@@ -36,7 +36,7 @@ import static org.assertj.core.api.Assertions.assertThat;
  * @author Daniel Garnier-Moiroux
  * @since 6.4
  */
-class DefaultResourcesCssWebFilterTests {
+class DefaultResourcesWebFilterTests {
 
 	private final WebHandler notFoundHandler = (exchange) -> {
 		exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND);