Skip to content

Commit f462134

Browse files
author
Steve Riesenberg
committed
Add reactive support for BREACH
Closes gh-11959
1 parent f4ca90e commit f462134

File tree

5 files changed

+414
-5
lines changed

5 files changed

+414
-5
lines changed

config/src/test/java/org/springframework/security/config/web/server/ServerHttpSecurityTests.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
import reactor.test.publisher.TestPublisher;
3434

3535
import org.springframework.http.HttpStatus;
36+
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
37+
import org.springframework.mock.web.server.MockServerWebExchange;
3638
import org.springframework.security.authentication.ReactiveAuthenticationManager;
3739
import org.springframework.security.authentication.TestingAuthenticationToken;
3840
import org.springframework.security.config.annotation.web.reactive.ServerHttpSecurityConfigurationBuilder;
@@ -69,6 +71,7 @@
6971
import org.springframework.security.web.server.csrf.DefaultCsrfToken;
7072
import org.springframework.security.web.server.csrf.ServerCsrfTokenRepository;
7173
import org.springframework.security.web.server.csrf.ServerCsrfTokenRequestHandler;
74+
import org.springframework.security.web.server.csrf.XorServerCsrfTokenRequestAttributeHandler;
7275
import org.springframework.security.web.server.savedrequest.ServerRequestCache;
7376
import org.springframework.security.web.server.savedrequest.WebSessionServerRequestCache;
7477
import org.springframework.test.util.ReflectionTestUtils;
@@ -526,6 +529,38 @@ public void postWhenCustomRequestHandlerThenUsed() {
526529
verify(requestHandler).resolveCsrfTokenValue(any(ServerWebExchange.class), any());
527530
}
528531

532+
@Test
533+
public void postWhenServerXorCsrfTokenRequestAttributeHandlerThenOk() {
534+
CsrfToken csrfToken = new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "token");
535+
given(this.csrfTokenRepository.loadToken(any(ServerWebExchange.class))).willReturn(Mono.just(csrfToken));
536+
given(this.csrfTokenRepository.generateToken(any(ServerWebExchange.class))).willReturn(Mono.empty());
537+
ServerCsrfTokenRequestHandler requestHandler = new XorServerCsrfTokenRequestAttributeHandler();
538+
// @formatter:off
539+
this.http.csrf((csrf) -> csrf
540+
.csrfTokenRepository(this.csrfTokenRepository)
541+
.csrfTokenRequestHandler(requestHandler)
542+
);
543+
// @formatter:on
544+
545+
// Generate masked CSRF token value
546+
ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/").build());
547+
requestHandler.handle(exchange, Mono.just(csrfToken));
548+
Mono<CsrfToken> csrfTokenAttribute = exchange.getAttribute(CsrfToken.class.getName());
549+
String actualTokenValue = csrfTokenAttribute.map(CsrfToken::getToken).block();
550+
assertThat(actualTokenValue).isNotEqualTo(csrfToken.getToken());
551+
552+
WebTestClient client = buildClient();
553+
// @formatter:off
554+
client.post()
555+
.uri("/")
556+
.header(csrfToken.getHeaderName(), actualTokenValue)
557+
.exchange()
558+
.expectStatus().isOk();
559+
// @formatter:on
560+
verify(this.csrfTokenRepository, times(2)).loadToken(any(ServerWebExchange.class));
561+
verify(this.csrfTokenRepository).generateToken(any(ServerWebExchange.class));
562+
}
563+
529564
@Test
530565
public void shouldConfigureRequestCacheForOAuth2LoginAuthenticationEntryPointAndSuccessHandler() {
531566
ServerRequestCache requestCache = spy(new WebSessionServerRequestCache());

docs/modules/ROOT/pages/reactive/exploits/csrf.adoc

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,54 @@ fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain
106106
-----
107107
====
108108

109+
[[webflux-csrf-configure-request-handler]]
110+
==== Configure ServerCsrfTokenRequestHandler
111+
112+
Spring Security's https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/server/csrf/CsrfWebFilter.html[`CsrfWebFilter`] exposes a https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/csrf/CsrfToken.html[`Mono<CsrfToken>`] as a `ServerWebExchange` attribute named `org.springframework.security.web.server.csrf.CsrfToken` with the help of a https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/server/csrf/ServerCsrfTokenRequestHandler.html[`ServerCsrfTokenRequestHandler`].
113+
The default implementation is `ServerCsrfTokenRequestAttributeHandler`.
114+
115+
An alternate implementation `XorServerCsrfTokenRequestAttributeHandler` is available to provide protection for BREACH (see https://github.com/spring-projects/spring-security/issues/4001[gh-4001]).
116+
117+
You can configure `XorServerCsrfTokenRequestAttributeHandler` using the following Java configuration:
118+
119+
.Configure BREACH protection
120+
====
121+
.Java
122+
[source,java,role="primary"]
123+
-----
124+
@Bean
125+
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
126+
http
127+
// ...
128+
.csrf(csrf -> csrf
129+
.csrfTokenRequestHandler(new XorServerCsrfTokenRequestAttributeHandler())
130+
)
131+
return http.build();
132+
}
133+
-----
134+
135+
.Kotlin
136+
[source,kotlin,role="secondary"]
137+
-----
138+
@Bean
139+
fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
140+
return http {
141+
// ...
142+
csrf {
143+
csrfTokenRequestHandler = XorServerCsrfTokenRequestAttributeHandler()
144+
}
145+
}
146+
}
147+
-----
148+
====
149+
109150
[[webflux-csrf-include]]
110151
=== Include the CSRF Token
111152

112153
In order for the xref:features/exploits/csrf.adoc#csrf-protection-stp[synchronizer token pattern] to protect against CSRF attacks, we must include the actual CSRF token in the HTTP request.
113154
This must be included in a part of the request (i.e. form parameter, HTTP header, etc) that is not automatically included in the HTTP request by the browser.
114155

115-
Spring Security's https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/server/csrf/CsrfWebFilter.html[CsrfWebFilter] exposes a https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/csrf/CsrfToken.html[Mono<CsrfToken>] as a `ServerWebExchange` attribute named `org.springframework.security.web.server.csrf.CsrfToken`.
156+
<<webflux-csrf-configure-request-handler,We've seen>> that the `Mono<CsrfToken>` is exposed as a `ServerWebExchange` attribute.
116157
This means that any view technology can access the `Mono<CsrfToken>` to expose the expected token as either a <<webflux-csrf-include-form-attr,form>> or <<webflux-csrf-include-ajax-meta,meta tag>>.
117158

118159
[[webflux-csrf-include-subscribe]]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Copyright 2002-2022 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.server.csrf;
18+
19+
import java.security.SecureRandom;
20+
import java.util.Base64;
21+
22+
import reactor.core.publisher.Mono;
23+
24+
import org.springframework.security.crypto.codec.Utf8;
25+
import org.springframework.util.Assert;
26+
import org.springframework.web.server.ServerWebExchange;
27+
28+
/**
29+
* An implementation of the {@link ServerCsrfTokenRequestAttributeHandler} and
30+
* {@link ServerCsrfTokenRequestResolver} interfaces that is capable of masking the value
31+
* of the {@link CsrfToken} on each request and resolving the raw token value from the
32+
* masked value as either a form data value or header of the request.
33+
*
34+
* @author Steve Riesenberg
35+
* @since 5.8
36+
*/
37+
public final class XorServerCsrfTokenRequestAttributeHandler extends ServerCsrfTokenRequestAttributeHandler {
38+
39+
private SecureRandom secureRandom = new SecureRandom();
40+
41+
/**
42+
* Specifies the {@code SecureRandom} used to generate random bytes that are used to
43+
* mask the value of the {@link CsrfToken} on each request.
44+
* @param secureRandom the {@code SecureRandom} to use to generate random bytes
45+
*/
46+
public void setSecureRandom(SecureRandom secureRandom) {
47+
Assert.notNull(secureRandom, "secureRandom cannot be null");
48+
this.secureRandom = secureRandom;
49+
}
50+
51+
@Override
52+
public void handle(ServerWebExchange exchange, Mono<CsrfToken> csrfToken) {
53+
Assert.notNull(exchange, "exchange cannot be null");
54+
Assert.notNull(csrfToken, "csrfToken cannot be null");
55+
Mono<CsrfToken> updatedCsrfToken = csrfToken.map((token) -> new DefaultCsrfToken(token.getHeaderName(),
56+
token.getParameterName(), createXoredCsrfToken(this.secureRandom, token.getToken())));
57+
super.handle(exchange, updatedCsrfToken);
58+
}
59+
60+
@Override
61+
public Mono<String> resolveCsrfTokenValue(ServerWebExchange exchange, CsrfToken csrfToken) {
62+
return super.resolveCsrfTokenValue(exchange, csrfToken)
63+
.flatMap((actualToken) -> Mono.justOrEmpty(getTokenValue(actualToken, csrfToken.getToken())));
64+
}
65+
66+
private static String getTokenValue(String actualToken, String token) {
67+
byte[] actualBytes;
68+
try {
69+
actualBytes = Base64.getUrlDecoder().decode(actualToken);
70+
}
71+
catch (Exception ex) {
72+
return null;
73+
}
74+
75+
byte[] tokenBytes = Utf8.encode(token);
76+
int tokenSize = tokenBytes.length;
77+
if (actualBytes.length < tokenSize) {
78+
return null;
79+
}
80+
81+
// extract token and random bytes
82+
int randomBytesSize = actualBytes.length - tokenSize;
83+
byte[] xoredCsrf = new byte[tokenSize];
84+
byte[] randomBytes = new byte[randomBytesSize];
85+
86+
System.arraycopy(actualBytes, 0, randomBytes, 0, randomBytesSize);
87+
System.arraycopy(actualBytes, randomBytesSize, xoredCsrf, 0, tokenSize);
88+
89+
byte[] csrfBytes = xorCsrf(randomBytes, xoredCsrf);
90+
return Utf8.decode(csrfBytes);
91+
}
92+
93+
private static String createXoredCsrfToken(SecureRandom secureRandom, String token) {
94+
byte[] tokenBytes = Utf8.encode(token);
95+
byte[] randomBytes = new byte[tokenBytes.length];
96+
secureRandom.nextBytes(randomBytes);
97+
98+
byte[] xoredBytes = xorCsrf(randomBytes, tokenBytes);
99+
byte[] combinedBytes = new byte[tokenBytes.length + randomBytes.length];
100+
System.arraycopy(randomBytes, 0, combinedBytes, 0, randomBytes.length);
101+
System.arraycopy(xoredBytes, 0, combinedBytes, randomBytes.length, xoredBytes.length);
102+
103+
return Base64.getUrlEncoder().encodeToString(combinedBytes);
104+
}
105+
106+
private static byte[] xorCsrf(byte[] randomBytes, byte[] csrfBytes) {
107+
int len = Math.min(randomBytes.length, csrfBytes.length);
108+
byte[] xoredCsrf = new byte[len];
109+
System.arraycopy(csrfBytes, 0, xoredCsrf, 0, csrfBytes.length);
110+
for (int i = 0; i < len; i++) {
111+
xoredCsrf[i] ^= randomBytes[i];
112+
}
113+
return xoredCsrf;
114+
}
115+
116+
}

web/src/test/java/org/springframework/security/web/server/csrf/CsrfWebFilterTests.java

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,44 @@ public void filterWhenRequestHandlerSetThenUsed() {
180180
verify(requestHandler).resolveCsrfTokenValue(this.post, this.token);
181181
}
182182

183+
@Test
184+
public void filterWhenXorServerCsrfTokenRequestProcessorAndValidTokenThenSuccess() {
185+
PublisherProbe<Void> chainResult = PublisherProbe.empty();
186+
given(this.chain.filter(any())).willReturn(chainResult.mono());
187+
this.csrfFilter.setCsrfTokenRepository(this.repository);
188+
given(this.repository.generateToken(any())).willReturn(Mono.just(this.token));
189+
given(this.repository.loadToken(any())).willReturn(Mono.just(this.token));
190+
XorServerCsrfTokenRequestAttributeHandler requestHandler = new XorServerCsrfTokenRequestAttributeHandler();
191+
this.csrfFilter.setRequestHandler(requestHandler);
192+
StepVerifier.create(this.csrfFilter.filter(this.get, this.chain)).verifyComplete();
193+
chainResult.assertWasSubscribed();
194+
195+
Mono<CsrfToken> csrfTokenAttribute = this.get.getAttribute(CsrfToken.class.getName());
196+
assertThat(csrfTokenAttribute).isNotNull();
197+
StepVerifier.create(csrfTokenAttribute)
198+
.consumeNextWith((csrfToken) -> this.post = MockServerWebExchange
199+
.from(MockServerHttpRequest.post("/").header(csrfToken.getHeaderName(), csrfToken.getToken())))
200+
.verifyComplete();
201+
202+
StepVerifier.create(this.csrfFilter.filter(this.post, this.chain)).verifyComplete();
203+
chainResult.assertWasSubscribed();
204+
}
205+
206+
@Test
207+
public void filterWhenXorServerCsrfTokenRequestProcessorAndRawTokenThenAccessDeniedException() {
208+
PublisherProbe<Void> chainResult = PublisherProbe.empty();
209+
this.csrfFilter.setCsrfTokenRepository(this.repository);
210+
given(this.repository.loadToken(any())).willReturn(Mono.just(this.token));
211+
XorServerCsrfTokenRequestAttributeHandler requestHandler = new XorServerCsrfTokenRequestAttributeHandler();
212+
this.csrfFilter.setRequestHandler(requestHandler);
213+
this.post = MockServerWebExchange
214+
.from(MockServerHttpRequest.post("/").header(this.token.getHeaderName(), this.token.getToken()));
215+
Mono<Void> result = this.csrfFilter.filter(this.post, this.chain);
216+
StepVerifier.create(result).verifyComplete();
217+
chainResult.assertWasNotSubscribed();
218+
assertThat(this.post.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
219+
}
220+
183221
@Test
184222
// gh-8452
185223
public void matchesRequireCsrfProtectionWhenNonStandardHTTPMethodIsUsed() {
@@ -215,7 +253,9 @@ public void filterWhenMultipartFormDataAndNotEnabledThenDenied() {
215253
@Test
216254
public void filterWhenMultipartFormDataAndEnabledThenGranted() {
217255
this.csrfFilter.setCsrfTokenRepository(this.repository);
218-
this.csrfFilter.setTokenFromMultipartDataEnabled(true);
256+
ServerCsrfTokenRequestAttributeHandler requestHandler = new ServerCsrfTokenRequestAttributeHandler();
257+
requestHandler.setTokenFromMultipartDataEnabled(true);
258+
this.csrfFilter.setRequestHandler(requestHandler);
219259
given(this.repository.loadToken(any())).willReturn(Mono.just(this.token));
220260
given(this.repository.generateToken(any())).willReturn(Mono.just(this.token));
221261
WebTestClient client = WebTestClient.bindToController(new OkController()).webFilter(this.csrfFilter).build();
@@ -227,7 +267,9 @@ public void filterWhenMultipartFormDataAndEnabledThenGranted() {
227267
@Test
228268
public void filterWhenPostAndMultipartFormDataEnabledAndNoBodyProvided() {
229269
this.csrfFilter.setCsrfTokenRepository(this.repository);
230-
this.csrfFilter.setTokenFromMultipartDataEnabled(true);
270+
ServerCsrfTokenRequestAttributeHandler requestHandler = new ServerCsrfTokenRequestAttributeHandler();
271+
requestHandler.setTokenFromMultipartDataEnabled(true);
272+
this.csrfFilter.setRequestHandler(requestHandler);
231273
given(this.repository.loadToken(any())).willReturn(Mono.just(this.token));
232274
given(this.repository.generateToken(any())).willReturn(Mono.just(this.token));
233275
WebTestClient client = WebTestClient.bindToController(new OkController()).webFilter(this.csrfFilter).build();
@@ -238,7 +280,9 @@ public void filterWhenPostAndMultipartFormDataEnabledAndNoBodyProvided() {
238280
@Test
239281
public void filterWhenFormDataAndEnabledThenGranted() {
240282
this.csrfFilter.setCsrfTokenRepository(this.repository);
241-
this.csrfFilter.setTokenFromMultipartDataEnabled(true);
283+
ServerCsrfTokenRequestAttributeHandler requestHandler = new ServerCsrfTokenRequestAttributeHandler();
284+
requestHandler.setTokenFromMultipartDataEnabled(true);
285+
this.csrfFilter.setRequestHandler(requestHandler);
242286
given(this.repository.loadToken(any())).willReturn(Mono.just(this.token));
243287
given(this.repository.generateToken(any())).willReturn(Mono.just(this.token));
244288
WebTestClient client = WebTestClient.bindToController(new OkController()).webFilter(this.csrfFilter).build();
@@ -250,7 +294,9 @@ public void filterWhenFormDataAndEnabledThenGranted() {
250294
@Test
251295
public void filterWhenMultipartMixedAndEnabledThenNotRead() {
252296
this.csrfFilter.setCsrfTokenRepository(this.repository);
253-
this.csrfFilter.setTokenFromMultipartDataEnabled(true);
297+
ServerCsrfTokenRequestAttributeHandler requestHandler = new ServerCsrfTokenRequestAttributeHandler();
298+
requestHandler.setTokenFromMultipartDataEnabled(true);
299+
this.csrfFilter.setRequestHandler(requestHandler);
254300
given(this.repository.loadToken(any())).willReturn(Mono.just(this.token));
255301
WebTestClient client = WebTestClient.bindToController(new OkController()).webFilter(this.csrfFilter).build();
256302
client.post().uri("/").contentType(MediaType.MULTIPART_MIXED)

0 commit comments

Comments
 (0)