Skip to content

Commit 54a6a19

Browse files
committed
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
1 parent 58534e7 commit 54a6a19

File tree

12 files changed

+147
-260
lines changed

12 files changed

+147
-260
lines changed

Diff for: docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc

+1-1
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ If used, the application's base URL, such as `https://app.example.org`, replaces
125125
[NOTE]
126126
====
127127
By default, `OidcClientInitiatedLogoutSuccessHandler` redirects to the logout URL using a standard HTTP redirect with the `GET` method.
128-
To perform the logout using a `POST` request, set the redirect strategy to `FormRedirectStrategy`, for example with `OidcClientInitiatedLogoutSuccessHandler.setRedirectStrategy(new FormRedirectStrategy())`.
128+
To perform the logout using a `POST` request, set the redirect strategy to `FormPostRedirectStrategy`, for example with `OidcClientInitiatedLogoutSuccessHandler.setRedirectStrategy(new FormPostRedirectStrategy())`.
129129
====
130130

131131
[[configure-provider-initiated-oidc-logout]]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Copyright 2002-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.web;
18+
19+
import java.io.IOException;
20+
import java.util.Base64;
21+
import java.util.List;
22+
import java.util.Map.Entry;
23+
24+
import jakarta.servlet.http.HttpServletRequest;
25+
import jakarta.servlet.http.HttpServletResponse;
26+
27+
import org.springframework.http.HttpStatus;
28+
import org.springframework.http.MediaType;
29+
import org.springframework.security.crypto.keygen.Base64StringKeyGenerator;
30+
import org.springframework.security.crypto.keygen.StringKeyGenerator;
31+
import org.springframework.web.util.HtmlUtils;
32+
import org.springframework.web.util.UriComponentsBuilder;
33+
34+
/**
35+
* Redirect using an auto-submitting HTML form using the POST method. All query params
36+
* provided in the URL are changed to inputs in the form so they are submitted as POST
37+
* data instead of query string data.
38+
*
39+
* @author Craig Andrews
40+
* @author Steve Riesenberg
41+
* @since 6.5
42+
*/
43+
public final class FormPostRedirectStrategy implements RedirectStrategy {
44+
45+
private static final String CONTENT_SECURITY_POLICY_HEADER = "Content-Security-Policy";
46+
47+
private static final String REDIRECT_PAGE_TEMPLATE = """
48+
<!DOCTYPE html>
49+
<html lang="en">
50+
<head>
51+
<meta charset="utf-8">
52+
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
53+
<meta name="description" content="">
54+
<meta name="author" content="">
55+
<title>Redirect</title>
56+
</head>
57+
<body>
58+
<form id="redirect-form" method="POST" action="{{action}}">
59+
{{params}}
60+
<noscript>
61+
<p>JavaScript is not enabled for this page.</p>
62+
<button type="submit">Click to continue</button>
63+
</noscript>
64+
</form>
65+
<script nonce="{{nonce}}">
66+
document.getElementById("redirect-form").submit();
67+
</script>
68+
</body>
69+
</html>
70+
""";
71+
72+
private static final String HIDDEN_INPUT_TEMPLATE = """
73+
<input name="{{name}}" type="hidden" value="{{value}}" />
74+
""";
75+
76+
private static final StringKeyGenerator DEFAULT_NONCE_GENERATOR = new Base64StringKeyGenerator(
77+
Base64.getUrlEncoder().withoutPadding(), 96);
78+
79+
@Override
80+
public void sendRedirect(final HttpServletRequest request, final HttpServletResponse response, final String url)
81+
throws IOException {
82+
final UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(url);
83+
84+
final StringBuilder hiddenInputsHtmlBuilder = new StringBuilder();
85+
for (final Entry<String, List<String>> entry : uriComponentsBuilder.build().getQueryParams().entrySet()) {
86+
final String name = entry.getKey();
87+
for (final String value : entry.getValue()) {
88+
// @formatter:off
89+
final String hiddenInput = HIDDEN_INPUT_TEMPLATE
90+
.replace("{{name}}", HtmlUtils.htmlEscape(name))
91+
.replace("{{value}}", HtmlUtils.htmlEscape(value));
92+
// @formatter:on
93+
hiddenInputsHtmlBuilder.append(hiddenInput.trim());
94+
}
95+
}
96+
97+
// Create the script-src policy directive for the Content-Security-Policy header
98+
final String nonce = DEFAULT_NONCE_GENERATOR.generateKey();
99+
final String policyDirective = "script-src 'nonce-%s'".formatted(nonce);
100+
101+
// @formatter:off
102+
final String html = REDIRECT_PAGE_TEMPLATE
103+
// Clear the query string as we don't want that to be part of the form action URL
104+
.replace("{{action}}", HtmlUtils.htmlEscape(uriComponentsBuilder.query(null).build().toUriString()))
105+
.replace("{{params}}", hiddenInputsHtmlBuilder.toString())
106+
.replace("{{nonce}}", HtmlUtils.htmlEscape(nonce));
107+
// @formatter:on
108+
109+
response.setStatus(HttpStatus.OK.value());
110+
response.setContentType(MediaType.TEXT_HTML_VALUE);
111+
response.setHeader(CONTENT_SECURITY_POLICY_HEADER, policyDirective);
112+
response.getWriter().write(html);
113+
response.getWriter().flush();
114+
}
115+
116+
}

Diff for: web/src/main/java/org/springframework/security/web/aot/hint/WebMvcSecurityRuntimeHints.java

-5
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,6 @@ public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
5454
hints.resources().registerResource(webauthnJavascript);
5555
}
5656

57-
ClassPathResource redirect = new ClassPathResource("org/springframework/security/form-redirect.js");
58-
if (redirect.exists()) {
59-
hints.resources().registerResource(redirect);
60-
}
61-
6257
}
6358

6459
}

Diff for: web/src/main/java/org/springframework/security/web/authentication/ui/DefaultResourcesFilter.java

-16
Original file line numberDiff line numberDiff line change
@@ -111,20 +111,4 @@ public static DefaultResourcesFilter webauthn() {
111111
new MediaType("text", "javascript", StandardCharsets.UTF_8));
112112
}
113113

114-
/**
115-
* Create an instance of {@link DefaultResourcesFilter} serving Spring Security's
116-
* default webauthn javascript.
117-
* <p>
118-
* The created {@link DefaultResourcesFilter} matches requests
119-
* {@code HTTP GET /form-redirect.js}, and returns the default webauthn javascript at
120-
* {@code org/springframework/security/form-redirect.js} with content-type
121-
* {@code text/javascript;charset=UTF-8}.
122-
* @return -
123-
*/
124-
public static DefaultResourcesFilter formRedirectJavascript() {
125-
return new DefaultResourcesFilter(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/form-redirect.js"),
126-
new ClassPathResource("org/springframework/security/form-redirect.js"),
127-
new MediaType("text", "javascript", StandardCharsets.UTF_8));
128-
}
129-
130114
}

Diff for: web/src/main/java/org/springframework/security/web/server/ui/DefaultResourcesWebFilter.java

-17
Original file line numberDiff line numberDiff line change
@@ -98,21 +98,4 @@ public static DefaultResourcesWebFilter css() {
9898
new MediaType("text", "css", StandardCharsets.UTF_8));
9999
}
100100

101-
/**
102-
* Create an instance of {@link DefaultResourcesWebFilter} serving Spring Security's
103-
* form redirect javascript.
104-
* <p>
105-
* The created {@link DefaultResourcesFilter} matches requests
106-
* {@code HTTP GET /form-redirect.js}, and returns the default javascript at
107-
* {@code org/springframework/security/form-redirect.js} with content-type
108-
* {@code text/javascript;charset=UTF-8}.
109-
* @return -
110-
*/
111-
public static DefaultResourcesWebFilter formRedirectJavascript() {
112-
return new DefaultResourcesWebFilter(
113-
new PathPatternParserServerWebExchangeMatcher("/form-redirect.js", HttpMethod.GET),
114-
new ClassPathResource("org/springframework/security/form-redirect.js"),
115-
new MediaType("text", "javascript", StandardCharsets.UTF_8));
116-
}
117-
118101
}

Diff for: web/src/main/java/org/springframework/security/web/server/ui/FormRedirectStrategy.java

-95
This file was deleted.

Diff for: web/src/main/resources/org/springframework/security/form-redirect.js

-1
This file was deleted.

Diff for: web/src/test/java/org/springframework/security/web/server/ui/FormRedirectStrategyTests.java renamed to web/src/test/java/org/springframework/security/web/FormPostRedirectStrategyTests.java

+29-10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2023 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -14,10 +14,11 @@
1414
* limitations under the License.
1515
*/
1616

17-
package org.springframework.security.web.server.ui;
17+
package org.springframework.security.web;
1818

1919
import java.io.IOException;
2020

21+
import org.assertj.core.api.ThrowingConsumer;
2122
import org.junit.jupiter.api.BeforeEach;
2223
import org.junit.jupiter.api.Test;
2324

@@ -30,17 +31,19 @@
3031

3132
import static org.assertj.core.api.Assertions.assertThat;
3233

33-
public class FormRedirectStrategyTests {
34+
public class FormPostRedirectStrategyTests {
3435

35-
private FormRedirectStrategy formRedirectStrategy;
36+
private static final String POLICY_DIRECTIVE_PATTERN = "script-src 'nonce-(.+)'";
37+
38+
private FormPostRedirectStrategy redirectStrategy;
3639

3740
private MockHttpServletRequest request;
3841

3942
private MockHttpServletResponse response;
4043

4144
@BeforeEach
4245
public void beforeEach() {
43-
this.formRedirectStrategy = new FormRedirectStrategy();
46+
this.redirectStrategy = new FormPostRedirectStrategy();
4447
final MockServletContext mockServletContext = new MockServletContext();
4548
mockServletContext.setContextPath("/contextPath");
4649
// the request URL doesn't matter
@@ -50,39 +53,43 @@ public void beforeEach() {
5053

5154
@Test
5255
public void absoluteUrlNoParametersRedirect() throws IOException {
53-
this.formRedirectStrategy.sendRedirect(this.request, this.response, "https://example.com");
56+
this.redirectStrategy.sendRedirect(this.request, this.response, "https://example.com");
5457
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value());
5558
assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE);
5659
assertThat(this.response.getContentAsString()).contains("action=\"https://example.com\"");
60+
assertThat(this.response).satisfies(hasScriptSrcNonce());
5761
}
5862

5963
@Test
6064
public void rootRelativeUrlNoParametersRedirect() throws IOException {
61-
this.formRedirectStrategy.sendRedirect(this.request, this.response, "/test");
65+
this.redirectStrategy.sendRedirect(this.request, this.response, "/test");
6266
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value());
6367
assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE);
6468
assertThat(this.response.getContentAsString()).contains("action=\"/test\"");
69+
assertThat(this.response).satisfies(hasScriptSrcNonce());
6570
}
6671

6772
@Test
6873
public void relativeUrlNoParametersRedirect() throws IOException {
69-
this.formRedirectStrategy.sendRedirect(this.request, this.response, "test");
74+
this.redirectStrategy.sendRedirect(this.request, this.response, "test");
7075
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value());
7176
assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE);
7277
assertThat(this.response.getContentAsString()).contains("action=\"test\"");
78+
assertThat(this.response).satisfies(hasScriptSrcNonce());
7379
}
7480

7581
@Test
7682
public void absoluteUrlWithFragmentRedirect() throws IOException {
77-
this.formRedirectStrategy.sendRedirect(this.request, this.response, "https://example.com/path#fragment");
83+
this.redirectStrategy.sendRedirect(this.request, this.response, "https://example.com/path#fragment");
7884
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value());
7985
assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE);
8086
assertThat(this.response.getContentAsString()).contains("action=\"https://example.com/path#fragment\"");
87+
assertThat(this.response).satisfies(hasScriptSrcNonce());
8188
}
8289

8390
@Test
8491
public void absoluteUrlWithQueryParamsRedirect() throws IOException {
85-
this.formRedirectStrategy.sendRedirect(this.request, this.response,
92+
this.redirectStrategy.sendRedirect(this.request, this.response,
8693
"https://example.com/path?param1=one&param2=two#fragment");
8794
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value());
8895
assertThat(this.response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE);
@@ -91,6 +98,18 @@ public void absoluteUrlWithQueryParamsRedirect() throws IOException {
9198
.contains("<input name=\"param1\" type=\"hidden\" value=\"one\" />");
9299
assertThat(this.response.getContentAsString())
93100
.contains("<input name=\"param2\" type=\"hidden\" value=\"two\" />");
101+
assertThat(this.response).satisfies(hasScriptSrcNonce());
102+
}
103+
104+
private ThrowingConsumer<MockHttpServletResponse> hasScriptSrcNonce() {
105+
return (response) -> {
106+
final String policyDirective = response.getHeader("Content-Security-Policy");
107+
assertThat(policyDirective).isNotEmpty();
108+
assertThat(policyDirective).matches(POLICY_DIRECTIVE_PATTERN);
109+
110+
final String nonce = policyDirective.replaceFirst(POLICY_DIRECTIVE_PATTERN, "$1");
111+
assertThat(response.getContentAsString()).contains("<script nonce=\"%s\">".formatted(nonce));
112+
};
94113
}
95114

96115
}

0 commit comments

Comments
 (0)