Add XorCsrfChannelInterceptor

Issue gh-12378
This commit is contained in:
Steve Riesenberg
2023-01-09 15:58:28 -06:00
parent d42405de42
commit c306df9b46
12 changed files with 501 additions and 58 deletions

View File

@@ -0,0 +1,86 @@
/*
* 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.messaging.web.csrf;
import java.security.MessageDigest;
import java.util.Map;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessageType;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.security.crypto.codec.Utf8;
import org.springframework.security.messaging.util.matcher.MessageMatcher;
import org.springframework.security.messaging.util.matcher.SimpMessageTypeMatcher;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.InvalidCsrfTokenException;
import org.springframework.security.web.csrf.MissingCsrfTokenException;
/**
* {@link ChannelInterceptor} that validates a CSRF token masked by the
* {@link org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler} in
* the header of any {@link SimpMessageType#CONNECT} message.
*
* @author Steve Riesenberg
* @since 5.8
*/
public final class XorCsrfChannelInterceptor implements ChannelInterceptor {
private final MessageMatcher<Object> matcher = new SimpMessageTypeMatcher(SimpMessageType.CONNECT);
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
if (!this.matcher.matches(message)) {
return message;
}
Map<String, Object> sessionAttributes = SimpMessageHeaderAccessor.getSessionAttributes(message.getHeaders());
CsrfToken expectedToken = (sessionAttributes != null)
? (CsrfToken) sessionAttributes.get(CsrfToken.class.getName()) : null;
if (expectedToken == null) {
throw new MissingCsrfTokenException(null);
}
String actualToken = SimpMessageHeaderAccessor.wrap(message)
.getFirstNativeHeader(expectedToken.getHeaderName());
String actualTokenValue = XorCsrfTokenUtils.getTokenValue(actualToken, expectedToken.getToken());
boolean csrfCheckPassed = equalsConstantTime(expectedToken.getToken(), actualTokenValue);
if (!csrfCheckPassed) {
throw new InvalidCsrfTokenException(expectedToken, actualToken);
}
return message;
}
/**
* Constant time comparison to prevent against timing attacks.
* @param expected
* @param actual
* @return
*/
private static boolean equalsConstantTime(String expected, String actual) {
if (expected == actual) {
return true;
}
if (expected == null || actual == null) {
return false;
}
// Encode after ensure that the string is not null
byte[] expectedBytes = Utf8.encode(expected);
byte[] actualBytes = Utf8.encode(actual);
return MessageDigest.isEqual(expectedBytes, actualBytes);
}
}

View File

@@ -0,0 +1,72 @@
/*
* 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.messaging.web.csrf;
import java.util.Base64;
import org.springframework.security.crypto.codec.Utf8;
/**
* Copied from
* {@link org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler}.
*
* @see <a href=
* "https://github.com/spring-projects/spring-security/issues/12378">gh-12378</a>
*/
final class XorCsrfTokenUtils {
private XorCsrfTokenUtils() {
}
static String getTokenValue(String actualToken, String token) {
byte[] actualBytes;
try {
actualBytes = Base64.getUrlDecoder().decode(actualToken);
}
catch (Exception ex) {
return null;
}
byte[] tokenBytes = Utf8.encode(token);
int tokenSize = tokenBytes.length;
if (actualBytes.length < tokenSize) {
return null;
}
// extract token and random bytes
int randomBytesSize = actualBytes.length - tokenSize;
byte[] xoredCsrf = new byte[tokenSize];
byte[] randomBytes = new byte[randomBytesSize];
System.arraycopy(actualBytes, 0, randomBytes, 0, randomBytesSize);
System.arraycopy(actualBytes, randomBytesSize, xoredCsrf, 0, tokenSize);
byte[] csrfBytes = xorCsrf(randomBytes, xoredCsrf);
return Utf8.decode(csrfBytes);
}
private static byte[] xorCsrf(byte[] randomBytes, byte[] csrfBytes) {
int len = Math.min(randomBytes.length, csrfBytes.length);
byte[] xoredCsrf = new byte[len];
System.arraycopy(csrfBytes, 0, xoredCsrf, 0, csrfBytes.length);
for (int i = 0; i < len; i++) {
xoredCsrf[i] ^= randomBytes[i];
}
return xoredCsrf;
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2015 the original author or authors.
* 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.
@@ -24,15 +24,18 @@ import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.DefaultCsrfToken;
import org.springframework.security.web.csrf.DeferredCsrfToken;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
/**
* Copies a CsrfToken from the HttpServletRequest's attributes to the WebSocket
* attributes. This is used as the expected CsrfToken when validating connection requests
* to ensure only the same origin connects.
* Loads a CsrfToken from the HttpServletRequest and HttpServletResponse to populate the
* WebSocket attributes. This is used as the expected CsrfToken when validating connection
* requests to ensure only the same origin connects.
*
* @author Rob Winch
* @author Steve Riesenberg
* @since 4.0
*/
public final class CsrfTokenHandshakeInterceptor implements HandshakeInterceptor {
@@ -41,11 +44,19 @@ public final class CsrfTokenHandshakeInterceptor implements HandshakeInterceptor
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
Map<String, Object> attributes) {
HttpServletRequest httpRequest = ((ServletServerHttpRequest) request).getServletRequest();
CsrfToken token = (CsrfToken) httpRequest.getAttribute(CsrfToken.class.getName());
if (token == null) {
DeferredCsrfToken deferredCsrfToken = (DeferredCsrfToken) httpRequest
.getAttribute(DeferredCsrfToken.class.getName());
if (deferredCsrfToken == null) {
return true;
}
attributes.put(CsrfToken.class.getName(), token);
CsrfToken csrfToken = deferredCsrfToken.get();
// Ensure the values of the CsrfToken are copied into a new token so the old token
// is available for garbage collection.
// This is required because the original token could hold a reference to the
// HttpServletRequest/Response of the handshake request.
CsrfToken resolvedCsrfToken = new DefaultCsrfToken(csrfToken.getHeaderName(), csrfToken.getParameterName(),
csrfToken.getToken());
attributes.put(CsrfToken.class.getName(), resolvedCsrfToken);
return true;
}

View File

@@ -0,0 +1,148 @@
/*
* 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.messaging.web.csrf;
import java.util.HashMap;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessageType;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.DefaultCsrfToken;
import org.springframework.security.web.csrf.InvalidCsrfTokenException;
import org.springframework.security.web.csrf.MissingCsrfTokenException;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link XorCsrfChannelInterceptor}.
*
* @author Steve Riesenberg
*/
public class XorCsrfChannelInterceptorTests {
private static final String XOR_CSRF_TOKEN_VALUE = "wpe7zB62-NCpcA==";
private static final String INVALID_XOR_CSRF_TOKEN_VALUE = "KneoaygbRZtfHQ==";
private CsrfToken token;
private SimpMessageHeaderAccessor messageHeaders;
private MessageChannel channel;
private XorCsrfChannelInterceptor interceptor;
@BeforeEach
public void setup() {
this.token = new DefaultCsrfToken("header", "param", "token");
this.messageHeaders = SimpMessageHeaderAccessor.create(SimpMessageType.CONNECT);
this.messageHeaders.setSessionAttributes(new HashMap<>());
this.channel = mock(MessageChannel.class);
this.interceptor = new XorCsrfChannelInterceptor();
}
@Test
public void preSendWhenConnectWithValidTokenThenSuccess() {
this.messageHeaders.setNativeHeader(this.token.getHeaderName(), XOR_CSRF_TOKEN_VALUE);
this.messageHeaders.getSessionAttributes().put(CsrfToken.class.getName(), this.token);
this.interceptor.preSend(message(), this.channel);
}
@Test
public void preSendWhenConnectWithInvalidTokenThenThrowsInvalidCsrfTokenException() {
this.messageHeaders.setNativeHeader(this.token.getHeaderName(), INVALID_XOR_CSRF_TOKEN_VALUE);
this.messageHeaders.getSessionAttributes().put(CsrfToken.class.getName(), this.token);
// @formatter:off
assertThatExceptionOfType(InvalidCsrfTokenException.class)
.isThrownBy(() -> this.interceptor.preSend(message(), mock(MessageChannel.class)));
// @formatter:on
}
@Test
public void preSendWhenConnectWithNoTokenThenThrowsInvalidCsrfTokenException() {
this.messageHeaders.getSessionAttributes().put(CsrfToken.class.getName(), this.token);
// @formatter:off
assertThatExceptionOfType(InvalidCsrfTokenException.class)
.isThrownBy(() -> this.interceptor.preSend(message(), mock(MessageChannel.class)));
// @formatter:on
}
@Test
public void preSendWhenConnectWithMissingTokenThenThrowsMissingCsrfTokenException() {
// @formatter:off
assertThatExceptionOfType(MissingCsrfTokenException.class)
.isThrownBy(() -> this.interceptor.preSend(message(), mock(MessageChannel.class)));
// @formatter:on
}
@Test
public void preSendWhenConnectWithNullSessionAttributesThenThrowsMissingCsrfTokenException() {
this.messageHeaders.setSessionAttributes(null);
// @formatter:off
assertThatExceptionOfType(MissingCsrfTokenException.class)
.isThrownBy(() -> this.interceptor.preSend(message(), mock(MessageChannel.class)));
// @formatter:on
}
@Test
public void preSendWhenAckThenIgnores() {
this.messageHeaders = SimpMessageHeaderAccessor.create(SimpMessageType.CONNECT_ACK);
this.interceptor.preSend(message(), this.channel);
}
@Test
public void preSendWhenDisconnectThenIgnores() {
this.messageHeaders = SimpMessageHeaderAccessor.create(SimpMessageType.DISCONNECT);
this.interceptor.preSend(message(), this.channel);
}
@Test
public void preSendWhenHeartbeatThenIgnores() {
this.messageHeaders = SimpMessageHeaderAccessor.create(SimpMessageType.HEARTBEAT);
this.interceptor.preSend(message(), this.channel);
}
@Test
public void preSendWhenMessageThenIgnores() {
this.messageHeaders = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
this.interceptor.preSend(message(), this.channel);
}
@Test
public void preSendWhenOtherThenIgnores() {
this.messageHeaders = SimpMessageHeaderAccessor.create(SimpMessageType.OTHER);
this.interceptor.preSend(message(), this.channel);
}
@Test
public void preSendWhenUnsubscribeThenIgnores() {
this.messageHeaders = SimpMessageHeaderAccessor.create(SimpMessageType.UNSUBSCRIBE);
this.interceptor.preSend(message(), this.channel);
}
private Message<String> message() {
return MessageBuilder.withPayload("message").copyHeaders(this.messageHeaders.toMap()).build();
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2016 the original author or authors.
* 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.
@@ -31,6 +31,7 @@ import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.DefaultCsrfToken;
import org.springframework.security.web.csrf.DeferredCsrfToken;
import org.springframework.web.socket.WebSocketHandler;
import static org.assertj.core.api.Assertions.assertThat;
@@ -72,10 +73,38 @@ public class CsrfTokenHandshakeInterceptorTests {
@Test
public void beforeHandshake() throws Exception {
CsrfToken token = new DefaultCsrfToken("header", "param", "token");
this.httpRequest.setAttribute(CsrfToken.class.getName(), token);
this.httpRequest.setAttribute(DeferredCsrfToken.class.getName(), new TestDeferredCsrfToken(token));
this.interceptor.beforeHandshake(this.request, this.response, this.wsHandler, this.attributes);
assertThat(this.attributes.keySet()).containsOnly(CsrfToken.class.getName());
assertThat(this.attributes.values()).containsOnly(token);
CsrfToken csrfToken = (CsrfToken) this.attributes.get(CsrfToken.class.getName());
assertThat(csrfToken.getHeaderName()).isEqualTo(token.getHeaderName());
assertThat(csrfToken.getParameterName()).isEqualTo(token.getParameterName());
assertThat(csrfToken.getToken()).isEqualTo(token.getToken());
// Ensure the values of the CsrfToken are copied into a new token so the old token
// is available for garbage collection.
// This is required because the original token could hold a reference to the
// HttpServletRequest/Response of the handshake request.
assertThat(csrfToken).isNotSameAs(token);
}
private static final class TestDeferredCsrfToken implements DeferredCsrfToken {
private final CsrfToken csrfToken;
private TestDeferredCsrfToken(CsrfToken csrfToken) {
this.csrfToken = csrfToken;
}
@Override
public CsrfToken get() {
return this.csrfToken;
}
@Override
public boolean isGenerated() {
return false;
}
}
}