Skip to content

Commit 6abbdd3

Browse files
author
Steve Riesenberg
committed
Merge branch '6.0.x'
2 parents 1243d13 + 179428f commit 6abbdd3

File tree

14 files changed

+518
-61
lines changed

14 files changed

+518
-61
lines changed

config/src/main/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfiguration.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 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.
@@ -41,7 +41,7 @@
4141
import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager;
4242
import org.springframework.security.messaging.context.AuthenticationPrincipalArgumentResolver;
4343
import org.springframework.security.messaging.context.SecurityContextChannelInterceptor;
44-
import org.springframework.security.messaging.web.csrf.CsrfChannelInterceptor;
44+
import org.springframework.security.messaging.web.csrf.XorCsrfChannelInterceptor;
4545
import org.springframework.security.messaging.web.socket.server.CsrfTokenHandshakeInterceptor;
4646
import org.springframework.util.Assert;
4747
import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping;
@@ -59,6 +59,8 @@ final class WebSocketMessageBrokerSecurityConfiguration
5959

6060
private static final String SIMPLE_URL_HANDLER_MAPPING_BEAN_NAME = "stompWebSocketHandlerMapping";
6161

62+
private static final String CSRF_CHANNEL_INTERCEPTOR_BEAN_NAME = "csrfChannelInterceptor";
63+
6264
private MessageMatcherDelegatingAuthorizationManager b;
6365

6466
private static final AuthorizationManager<Message<?>> ANY_MESSAGE_AUTHENTICATED = MessageMatcherDelegatingAuthorizationManager
@@ -69,7 +71,7 @@ final class WebSocketMessageBrokerSecurityConfiguration
6971

7072
private final SecurityContextChannelInterceptor securityContextChannelInterceptor = new SecurityContextChannelInterceptor();
7173

72-
private final ChannelInterceptor csrfChannelInterceptor = new CsrfChannelInterceptor();
74+
private ChannelInterceptor csrfChannelInterceptor = new XorCsrfChannelInterceptor();
7375

7476
private AuthorizationManager<Message<?>> authorizationManager = ANY_MESSAGE_AUTHENTICATED;
7577

@@ -90,6 +92,12 @@ public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentRes
9092

9193
@Override
9294
public void configureClientInboundChannel(ChannelRegistration registration) {
95+
ChannelInterceptor csrfChannelInterceptor = getBeanOrNull(CSRF_CHANNEL_INTERCEPTOR_BEAN_NAME,
96+
ChannelInterceptor.class);
97+
if (csrfChannelInterceptor != null) {
98+
this.csrfChannelInterceptor = csrfChannelInterceptor;
99+
}
100+
93101
AuthorizationManager<Message<?>> manager = this.authorizationManager;
94102
if (!this.observationRegistry.isNoop()) {
95103
manager = new ObservationAuthorizationManager<>(this.observationRegistry, manager);

config/src/test/java/org/springframework/security/config/annotation/web/socket/AbstractSecurityWebSocketMessageBrokerConfigurerTests.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2019 the original author or authors.
2+
* Copyright 2002-2023 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.
@@ -60,6 +60,7 @@
6060
import org.springframework.security.messaging.web.csrf.CsrfChannelInterceptor;
6161
import org.springframework.security.web.csrf.CsrfToken;
6262
import org.springframework.security.web.csrf.DefaultCsrfToken;
63+
import org.springframework.security.web.csrf.DeferredCsrfToken;
6364
import org.springframework.security.web.csrf.MissingCsrfTokenException;
6465
import org.springframework.stereotype.Controller;
6566
import org.springframework.test.util.ReflectionTestUtils;
@@ -78,6 +79,7 @@
7879

7980
import static org.assertj.core.api.Assertions.assertThat;
8081
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
82+
import static org.springframework.security.web.csrf.CsrfTokenAssert.assertThatCsrfToken;
8183

8284
public class AbstractSecurityWebSocketMessageBrokerConfigurerTests {
8385

@@ -283,7 +285,7 @@ public void inboundChannelSecurityDefinedByBean() {
283285

284286
private void assertHandshake(HttpServletRequest request) {
285287
TestHandshakeHandler handshakeHandler = this.context.getBean(TestHandshakeHandler.class);
286-
assertThat(handshakeHandler.attributes.get(CsrfToken.class.getName())).isSameAs(this.token);
288+
assertThatCsrfToken(handshakeHandler.attributes.get(CsrfToken.class.getName())).isEqualTo(this.token);
287289
assertThat(handshakeHandler.attributes.get(this.sessionAttr))
288290
.isEqualTo(request.getSession().getAttribute(this.sessionAttr));
289291
}
@@ -305,7 +307,7 @@ private MockHttpServletRequest sockjsHttpRequest(String mapping) {
305307
request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "/289/tpyx6mde/websocket");
306308
request.setRequestURI(mapping + "/289/tpyx6mde/websocket");
307309
request.getSession().setAttribute(this.sessionAttr, "sessionValue");
308-
request.setAttribute(CsrfToken.class.getName(), this.token);
310+
request.setAttribute(DeferredCsrfToken.class.getName(), new TestDeferredCsrfToken(this.token));
309311
return request;
310312
}
311313

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright 2002-2023 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.config.annotation.web.socket;
18+
19+
import org.springframework.security.web.csrf.CsrfToken;
20+
import org.springframework.security.web.csrf.DeferredCsrfToken;
21+
22+
/**
23+
* @author Steve Riesenberg
24+
*/
25+
final class TestDeferredCsrfToken implements DeferredCsrfToken {
26+
27+
private final CsrfToken csrfToken;
28+
29+
TestDeferredCsrfToken(CsrfToken csrfToken) {
30+
this.csrfToken = csrfToken;
31+
}
32+
33+
@Override
34+
public CsrfToken get() {
35+
return this.csrfToken;
36+
}
37+
38+
@Override
39+
public boolean isGenerated() {
40+
return false;
41+
}
42+
43+
}

config/src/test/java/org/springframework/security/config/annotation/web/socket/WebSocketMessageBrokerSecurityConfigurationTests.java

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 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.
@@ -66,9 +66,10 @@
6666
import org.springframework.security.messaging.access.intercept.MessageAuthorizationContext;
6767
import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager;
6868
import org.springframework.security.messaging.context.SecurityContextChannelInterceptor;
69-
import org.springframework.security.messaging.web.csrf.CsrfChannelInterceptor;
69+
import org.springframework.security.messaging.web.csrf.XorCsrfChannelInterceptor;
7070
import org.springframework.security.web.csrf.CsrfToken;
7171
import org.springframework.security.web.csrf.DefaultCsrfToken;
72+
import org.springframework.security.web.csrf.DeferredCsrfToken;
7273
import org.springframework.security.web.csrf.MissingCsrfTokenException;
7374
import org.springframework.stereotype.Controller;
7475
import org.springframework.test.util.ReflectionTestUtils;
@@ -91,9 +92,12 @@
9192
import static org.assertj.core.api.Assertions.fail;
9293
import static org.mockito.Mockito.atLeastOnce;
9394
import static org.mockito.Mockito.verify;
95+
import static org.springframework.security.web.csrf.CsrfTokenAssert.assertThatCsrfToken;
9496

9597
public class WebSocketMessageBrokerSecurityConfigurationTests {
9698

99+
private static final String XOR_CSRF_TOKEN_VALUE = "wpe7zB62-NCpcA==";
100+
97101
AnnotationConfigWebApplicationContext context;
98102

99103
Authentication messageUser;
@@ -196,7 +200,7 @@ public void csrfProtectionDefinedByBean() {
196200
MessageChannel messageChannel = clientInboundChannel();
197201
Stream<Class<? extends ChannelInterceptor>> interceptors = ((AbstractMessageChannel) messageChannel)
198202
.getInterceptors().stream().map(ChannelInterceptor::getClass);
199-
assertThat(interceptors).contains(CsrfChannelInterceptor.class);
203+
assertThat(interceptors).contains(XorCsrfChannelInterceptor.class);
200204
}
201205

202206
@Test
@@ -236,7 +240,7 @@ public void messagesConnectWebSocketUseCsrfTokenHandshakeInterceptor() throws Ex
236240
public void messagesContextWebSocketUseSecurityContextHolderStrategy() {
237241
loadConfig(WebSocketSecurityConfig.class, SecurityContextChangedListenerConfig.class);
238242
SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.create(SimpMessageType.CONNECT);
239-
headers.setNativeHeader(this.token.getHeaderName(), this.token.getToken());
243+
headers.setNativeHeader(this.token.getHeaderName(), XOR_CSRF_TOKEN_VALUE);
240244
Message<?> message = message(headers, "/authenticated");
241245
headers.getSessionAttributes().put(CsrfToken.class.getName(), this.token);
242246
MessageChannel messageChannel = clientInboundChannel();
@@ -366,7 +370,7 @@ public void sendMessageWhenAnonymousConfiguredAndLoggedInUserThenAccessDeniedExc
366370

367371
private void assertHandshake(HttpServletRequest request) {
368372
TestHandshakeHandler handshakeHandler = this.context.getBean(TestHandshakeHandler.class);
369-
assertThat(handshakeHandler.attributes.get(CsrfToken.class.getName())).isSameAs(this.token);
373+
assertThatCsrfToken(handshakeHandler.attributes.get(CsrfToken.class.getName())).isEqualTo(this.token);
370374
assertThat(handshakeHandler.attributes.get(this.sessionAttr))
371375
.isEqualTo(request.getSession().getAttribute(this.sessionAttr));
372376
}
@@ -388,7 +392,7 @@ private MockHttpServletRequest sockjsHttpRequest(String mapping) {
388392
request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "/289/tpyx6mde/websocket");
389393
request.setRequestURI(mapping + "/289/tpyx6mde/websocket");
390394
request.getSession().setAttribute(this.sessionAttr, "sessionValue");
391-
request.setAttribute(CsrfToken.class.getName(), this.token);
395+
request.setAttribute(DeferredCsrfToken.class.getName(), new TestDeferredCsrfToken(this.token));
392396
return request;
393397
}
394398

config/src/test/java/org/springframework/security/config/websocket/WebSocketMessageBrokerConfigTests.java

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 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.
@@ -61,6 +61,7 @@
6161
import org.springframework.security.test.context.support.WithMockUser;
6262
import org.springframework.security.web.csrf.CsrfToken;
6363
import org.springframework.security.web.csrf.DefaultCsrfToken;
64+
import org.springframework.security.web.csrf.DeferredCsrfToken;
6465
import org.springframework.security.web.csrf.InvalidCsrfTokenException;
6566
import org.springframework.stereotype.Controller;
6667
import org.springframework.test.context.junit.jupiter.SpringExtension;
@@ -77,6 +78,7 @@
7778
import static org.mockito.ArgumentMatchers.any;
7879
import static org.mockito.BDDMockito.given;
7980
import static org.mockito.Mockito.verify;
81+
import static org.springframework.security.web.csrf.CsrfTokenAssert.assertThatCsrfToken;
8082
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
8183

8284
/**
@@ -381,12 +383,14 @@ public void requestWhenConnectMessageThenUsesCsrfTokenHandshakeInterceptor() thr
381383
MockMvc mvc = MockMvcBuilders.webAppContextSetup(context).build();
382384
String csrfAttributeName = CsrfToken.class.getName();
383385
String customAttributeName = this.getClass().getName();
384-
MvcResult result = mvc.perform(get("/app").requestAttr(csrfAttributeName, this.token)
385-
.sessionAttr(customAttributeName, "attributeValue")).andReturn();
386+
MvcResult result = mvc.perform(
387+
get("/app").requestAttr(DeferredCsrfToken.class.getName(), new TestDeferredCsrfToken(this.token))
388+
.sessionAttr(customAttributeName, "attributeValue"))
389+
.andReturn();
386390
CsrfToken handshakeToken = (CsrfToken) this.testHandshakeHandler.attributes.get(csrfAttributeName);
387391
String handshakeValue = (String) this.testHandshakeHandler.attributes.get(customAttributeName);
388392
String sessionValue = (String) result.getRequest().getSession().getAttribute(customAttributeName);
389-
assertThat(handshakeToken).isEqualTo(this.token).withFailMessage("CsrfToken is populated");
393+
assertThatCsrfToken(handshakeToken).isEqualTo(this.token).withFailMessage("CsrfToken is populated");
390394
assertThat(handshakeValue).isEqualTo(sessionValue)
391395
.withFailMessage("Explicitly listed session variables are not overridden");
392396
}
@@ -398,12 +402,13 @@ public void requestWhenConnectMessageAndUsingSockJsThenUsesCsrfTokenHandshakeInt
398402
MockMvc mvc = MockMvcBuilders.webAppContextSetup(context).build();
399403
String csrfAttributeName = CsrfToken.class.getName();
400404
String customAttributeName = this.getClass().getName();
401-
MvcResult result = mvc.perform(get("/app/289/tpyx6mde/websocket").requestAttr(csrfAttributeName, this.token)
405+
MvcResult result = mvc.perform(get("/app/289/tpyx6mde/websocket")
406+
.requestAttr(DeferredCsrfToken.class.getName(), new TestDeferredCsrfToken(this.token))
402407
.sessionAttr(customAttributeName, "attributeValue")).andReturn();
403408
CsrfToken handshakeToken = (CsrfToken) this.testHandshakeHandler.attributes.get(csrfAttributeName);
404409
String handshakeValue = (String) this.testHandshakeHandler.attributes.get(customAttributeName);
405410
String sessionValue = (String) result.getRequest().getSession().getAttribute(customAttributeName);
406-
assertThat(handshakeToken).isEqualTo(this.token).withFailMessage("CsrfToken is populated");
411+
assertThatCsrfToken(handshakeToken).isEqualTo(this.token).withFailMessage("CsrfToken is populated");
407412
assertThat(handshakeValue).isEqualTo(sessionValue)
408413
.withFailMessage("Explicitly listed session variables are not overridden");
409414
}
@@ -526,6 +531,26 @@ private SecurityContextHolderStrategy getSecurityContextHolderStrategy() {
526531
return SecurityContextHolder.getContextHolderStrategy();
527532
}
528533

534+
private static final class TestDeferredCsrfToken implements DeferredCsrfToken {
535+
536+
private final CsrfToken csrfToken;
537+
538+
TestDeferredCsrfToken(CsrfToken csrfToken) {
539+
this.csrfToken = csrfToken;
540+
}
541+
542+
@Override
543+
public CsrfToken get() {
544+
return this.csrfToken;
545+
}
546+
547+
@Override
548+
public boolean isGenerated() {
549+
return false;
550+
}
551+
552+
}
553+
529554
@Controller
530555
static class MessageController {
531556

docs/modules/ROOT/nav.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* xref:migration/index.adoc[Migrating to 6.0]
66
** xref:migration/servlet/index.adoc[Servlet Migrations]
77
*** xref:migration/servlet/session-management.adoc[Session Management]
8+
*** xref:migration/servlet/exploits.adoc[Exploit Protection]
89
*** xref:migration/servlet/authentication.adoc[Authentication]
910
*** xref:migration/servlet/authorization.adoc[Authorization]
1011
** xref:migration/reactive.adoc[Reactive Migrations]
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
= Exploit Protection Migrations
2+
3+
The following steps relate to how to finish migrating exploit protection support.
4+
5+
== CSRF BREACH with WebSocket support
6+
7+
In Spring Security 5.8, the default `ChannelInterceptor` for making the `CsrfToken` available with xref:servlet/integrations/websocket.adoc[WebSocket Security] is `CsrfChannelInterceptor`.
8+
`XorCsrfChannelInterceptor` was added to allow opting into CSRF BREACH support.
9+
10+
In Spring Security 6, `XorCsrfChannelInterceptor` is the default `ChannelInterceptor` for making the `CsrfToken` available.
11+
If you configured the `XorCsrfChannelInterceptor` only for the purpose of updating to 6.0, you can remove it completely.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* Copyright 2002-2023 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.messaging.web.csrf;
18+
19+
import java.security.MessageDigest;
20+
import java.util.Map;
21+
22+
import org.springframework.messaging.Message;
23+
import org.springframework.messaging.MessageChannel;
24+
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
25+
import org.springframework.messaging.simp.SimpMessageType;
26+
import org.springframework.messaging.support.ChannelInterceptor;
27+
import org.springframework.security.crypto.codec.Utf8;
28+
import org.springframework.security.messaging.util.matcher.MessageMatcher;
29+
import org.springframework.security.messaging.util.matcher.SimpMessageTypeMatcher;
30+
import org.springframework.security.web.csrf.CsrfToken;
31+
import org.springframework.security.web.csrf.InvalidCsrfTokenException;
32+
import org.springframework.security.web.csrf.MissingCsrfTokenException;
33+
34+
/**
35+
* {@link ChannelInterceptor} that validates a CSRF token masked by the
36+
* {@link org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler} in
37+
* the header of any {@link SimpMessageType#CONNECT} message.
38+
*
39+
* @author Steve Riesenberg
40+
* @since 5.8
41+
*/
42+
public final class XorCsrfChannelInterceptor implements ChannelInterceptor {
43+
44+
private final MessageMatcher<Object> matcher = new SimpMessageTypeMatcher(SimpMessageType.CONNECT);
45+
46+
@Override
47+
public Message<?> preSend(Message<?> message, MessageChannel channel) {
48+
if (!this.matcher.matches(message)) {
49+
return message;
50+
}
51+
Map<String, Object> sessionAttributes = SimpMessageHeaderAccessor.getSessionAttributes(message.getHeaders());
52+
CsrfToken expectedToken = (sessionAttributes != null)
53+
? (CsrfToken) sessionAttributes.get(CsrfToken.class.getName()) : null;
54+
if (expectedToken == null) {
55+
throw new MissingCsrfTokenException(null);
56+
}
57+
String actualToken = SimpMessageHeaderAccessor.wrap(message)
58+
.getFirstNativeHeader(expectedToken.getHeaderName());
59+
String actualTokenValue = XorCsrfTokenUtils.getTokenValue(actualToken, expectedToken.getToken());
60+
boolean csrfCheckPassed = equalsConstantTime(expectedToken.getToken(), actualTokenValue);
61+
if (!csrfCheckPassed) {
62+
throw new InvalidCsrfTokenException(expectedToken, actualToken);
63+
}
64+
return message;
65+
}
66+
67+
/**
68+
* Constant time comparison to prevent against timing attacks.
69+
* @param expected
70+
* @param actual
71+
* @return
72+
*/
73+
private static boolean equalsConstantTime(String expected, String actual) {
74+
if (expected == actual) {
75+
return true;
76+
}
77+
if (expected == null || actual == null) {
78+
return false;
79+
}
80+
// Encode after ensure that the string is not null
81+
byte[] expectedBytes = Utf8.encode(expected);
82+
byte[] actualBytes = Utf8.encode(actual);
83+
return MessageDigest.isEqual(expectedBytes, actualBytes);
84+
}
85+
86+
}

0 commit comments

Comments
 (0)