Skip to content

Commit 9db12e4

Browse files
refactor(csrf): Extract XOR CSRF token logic into reusable encoder
Moved XOR-based CSRF token encoding/decoding logic into a new public class `XorCsrfTokenEncoder` that implements the `CsrfTokenEncoder` interface. This improves testability, readability, and separation of concerns. - Created `CsrfTokenEncoder` interface to define encoding/decoding contract - Implemented `XorCsrfTokenEncoder` with secure random masking logic - Updated `XorCsrfTokenRequestAttributeHandler` to delegate to the encoder - Added support for injecting custom `SecureRandom` instance - Preserved existing behavior and encoding mechanism This refactor enables easier unit testing and future extensibility. Signed-off-by: Cheol Jeon <nieuwmijnleven@outlook.com>
1 parent f3761af commit 9db12e4

File tree

4 files changed

+257
-65
lines changed

4 files changed

+257
-65
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright 2004-present 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.csrf;
18+
19+
import org.jspecify.annotations.Nullable;
20+
21+
/**
22+
* Interface for encoding and decoding CSRF tokens.
23+
*
24+
* Defines methods to encode a CSRF token and to decode an encoded token
25+
* by referencing the original unencoded token.
26+
*
27+
* This is primarily used to safely transform CSRF tokens for security purposes.
28+
*
29+
* @author Cheol Jeon
30+
* @since
31+
* @see XorCsrfTokenEncoder
32+
*/
33+
public interface CsrfTokenEncoder {
34+
35+
String encode(String token);
36+
37+
/**
38+
* Decodes the encoded CSRF token using the original unencoded token.
39+
* This is necessary because the decoding process requires the original token length.
40+
*/
41+
@Nullable
42+
String decode(String encodedToken, String originalToken);
43+
44+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*
2+
* Copyright 2004-present 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.csrf;
18+
19+
import org.jspecify.annotations.Nullable;
20+
import org.springframework.core.log.LogMessage;
21+
import org.springframework.security.crypto.codec.Utf8;
22+
import org.springframework.util.Assert;
23+
24+
import java.security.SecureRandom;
25+
import java.util.Base64;
26+
27+
import static org.springframework.security.web.csrf.CsrfTokenRequestHandlerLoggerHolder.logger;
28+
29+
/**
30+
* Implementation of CsrfTokenEncoder that uses XOR operation combined with a random key
31+
* to encode and decode CSRF tokens.
32+
*
33+
* The encode method generates a random byte array and XORs it with the UTF-8 bytes of the token,
34+
* then combines both arrays and encodes them in Base64 URL-safe format.
35+
*
36+
* The decode method reverses this process by decoding the Base64 string, splitting the bytes,
37+
* and XORing the two parts to retrieve the original token.
38+
*
39+
* This approach enhances CSRF token security by obfuscating the token value with randomness.
40+
*
41+
* @author Cheol Jeon
42+
* @since
43+
* @see XorCsrfTokenRequestAttributeHandler
44+
*/
45+
public class XorCsrfTokenEncoder implements CsrfTokenEncoder {
46+
private SecureRandom secureRandom;
47+
48+
public XorCsrfTokenEncoder() {
49+
this(new SecureRandom());
50+
}
51+
52+
public XorCsrfTokenEncoder(SecureRandom secureRandom) {
53+
Assert.notNull(secureRandom, "secureRandom cannot be null");
54+
this.secureRandom = secureRandom;
55+
}
56+
57+
@Override
58+
public String encode(String token) {
59+
byte[] tokenBytes = Utf8.encode(token);
60+
byte[] randomBytes = new byte[tokenBytes.length];
61+
secureRandom.nextBytes(randomBytes);
62+
63+
byte[] xoredBytes = xor(randomBytes, tokenBytes);
64+
byte[] combinedBytes = new byte[tokenBytes.length + randomBytes.length];
65+
System.arraycopy(randomBytes, 0, combinedBytes, 0, randomBytes.length);
66+
System.arraycopy(xoredBytes, 0, combinedBytes, randomBytes.length, xoredBytes.length);
67+
68+
return Base64.getUrlEncoder().encodeToString(combinedBytes);
69+
}
70+
71+
@Override
72+
public @Nullable String decode(String encodedToken, String originalToken) {
73+
byte[] actualBytes;
74+
try {
75+
actualBytes = Base64.getUrlDecoder().decode(encodedToken);
76+
}
77+
catch (Exception ex) {
78+
logger.trace(LogMessage.format("Not returning the CSRF token since it's not Base64-encoded"), ex);
79+
return null;
80+
}
81+
82+
byte[] tokenBytes = Utf8.encode(originalToken);
83+
int tokenSize = tokenBytes.length;
84+
if (actualBytes.length != tokenSize * 2) {
85+
logger.trace(LogMessage.format(
86+
"Not returning the CSRF token since its Base64-decoded length (%d) is not equal to (%d)",
87+
actualBytes.length, tokenSize * 2));
88+
return null;
89+
}
90+
91+
// extract token and random bytes
92+
byte[] xoredCsrf = new byte[tokenSize];
93+
byte[] randomBytes = new byte[tokenSize];
94+
95+
System.arraycopy(actualBytes, 0, randomBytes, 0, tokenSize);
96+
System.arraycopy(actualBytes, tokenSize, xoredCsrf, 0, tokenSize);
97+
98+
byte[] csrfBytes = xor(randomBytes, xoredCsrf);
99+
return Utf8.decode(csrfBytes);
100+
}
101+
102+
private byte[] xor(byte[] randomBytes, byte[] csrfBytes) {
103+
Assert.isTrue(randomBytes.length == csrfBytes.length, "arrays must be equal length");
104+
int len = csrfBytes.length;
105+
byte[] xoredCsrf = new byte[len];
106+
System.arraycopy(csrfBytes, 0, xoredCsrf, 0, len);
107+
for (int i = 0; i < len; i++) {
108+
xoredCsrf[i] ^= randomBytes[i];
109+
}
110+
return xoredCsrf;
111+
}
112+
}

web/src/main/java/org/springframework/security/web/csrf/XorCsrfTokenRequestAttributeHandler.java

Lines changed: 8 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -16,34 +16,31 @@
1616

1717
package org.springframework.security.web.csrf;
1818

19-
import java.security.SecureRandom;
20-
import java.util.Base64;
21-
import java.util.function.Supplier;
22-
2319
import jakarta.servlet.http.HttpServletRequest;
2420
import jakarta.servlet.http.HttpServletResponse;
2521
import org.apache.commons.logging.Log;
2622
import org.apache.commons.logging.LogFactory;
2723
import org.jspecify.annotations.Nullable;
28-
29-
import org.springframework.core.log.LogMessage;
30-
import org.springframework.security.crypto.codec.Utf8;
3124
import org.springframework.util.Assert;
3225

26+
import java.security.SecureRandom;
27+
import java.util.function.Supplier;
28+
3329
/**
3430
* An implementation of the {@link CsrfTokenRequestHandler} interface that is capable of
3531
* masking the value of the {@link CsrfToken} on each request and resolving the raw token
3632
* value from the masked value as either a header or parameter value of the request.
3733
*
3834
* @author Steve Riesenberg
3935
* @author Yoobin Yoon
36+
* @author Cheol Jeon
4037
* @since 5.8
4138
*/
4239
public final class XorCsrfTokenRequestAttributeHandler extends CsrfTokenRequestAttributeHandler {
4340

4441
private static final Log logger = LogFactory.getLog(XorCsrfTokenRequestAttributeHandler.class);
4542

46-
private SecureRandom secureRandom = new SecureRandom();
43+
private CsrfTokenEncoder csrfTokenEncoder = new XorCsrfTokenEncoder();
4744

4845
/**
4946
* Specifies the {@code SecureRandom} used to generate random bytes that are used to
@@ -52,7 +49,7 @@ public final class XorCsrfTokenRequestAttributeHandler extends CsrfTokenRequestA
5249
*/
5350
public void setSecureRandom(SecureRandom secureRandom) {
5451
Assert.notNull(secureRandom, "secureRandom cannot be null");
55-
this.secureRandom = secureRandom;
52+
this.csrfTokenEncoder = new XorCsrfTokenEncoder(secureRandom);
5653
}
5754

5855
@Override
@@ -69,7 +66,7 @@ private Supplier<CsrfToken> deferCsrfTokenUpdate(Supplier<CsrfToken> csrfTokenSu
6966
return new CachedCsrfTokenSupplier(() -> {
7067
CsrfToken csrfToken = csrfTokenSupplier.get();
7168
Assert.state(csrfToken != null, "csrfToken supplier returned null");
72-
String updatedToken = createXoredCsrfToken(this.secureRandom, csrfToken.getToken());
69+
String updatedToken = csrfTokenEncoder.encode(csrfToken.getToken());
7370
return new DefaultCsrfToken(csrfToken.getHeaderName(), csrfToken.getParameterName(), updatedToken);
7471
});
7572
}
@@ -80,61 +77,7 @@ private Supplier<CsrfToken> deferCsrfTokenUpdate(Supplier<CsrfToken> csrfTokenSu
8077
if (actualToken == null) {
8178
return null;
8279
}
83-
return getTokenValue(actualToken, csrfToken.getToken());
84-
}
85-
86-
private static @Nullable String getTokenValue(String actualToken, String token) {
87-
byte[] actualBytes;
88-
try {
89-
actualBytes = Base64.getUrlDecoder().decode(actualToken);
90-
}
91-
catch (Exception ex) {
92-
logger.trace(LogMessage.format("Not returning the CSRF token since it's not Base64-encoded"), ex);
93-
return null;
94-
}
95-
96-
byte[] tokenBytes = Utf8.encode(token);
97-
int tokenSize = tokenBytes.length;
98-
if (actualBytes.length != tokenSize * 2) {
99-
logger.trace(LogMessage.format(
100-
"Not returning the CSRF token since its Base64-decoded length (%d) is not equal to (%d)",
101-
actualBytes.length, tokenSize * 2));
102-
return null;
103-
}
104-
105-
// extract token and random bytes
106-
byte[] xoredCsrf = new byte[tokenSize];
107-
byte[] randomBytes = new byte[tokenSize];
108-
109-
System.arraycopy(actualBytes, 0, randomBytes, 0, tokenSize);
110-
System.arraycopy(actualBytes, tokenSize, xoredCsrf, 0, tokenSize);
111-
112-
byte[] csrfBytes = xorCsrf(randomBytes, xoredCsrf);
113-
return Utf8.decode(csrfBytes);
114-
}
115-
116-
private static String createXoredCsrfToken(SecureRandom secureRandom, String token) {
117-
byte[] tokenBytes = Utf8.encode(token);
118-
byte[] randomBytes = new byte[tokenBytes.length];
119-
secureRandom.nextBytes(randomBytes);
120-
121-
byte[] xoredBytes = xorCsrf(randomBytes, tokenBytes);
122-
byte[] combinedBytes = new byte[tokenBytes.length + randomBytes.length];
123-
System.arraycopy(randomBytes, 0, combinedBytes, 0, randomBytes.length);
124-
System.arraycopy(xoredBytes, 0, combinedBytes, randomBytes.length, xoredBytes.length);
125-
126-
return Base64.getUrlEncoder().encodeToString(combinedBytes);
127-
}
128-
129-
private static byte[] xorCsrf(byte[] randomBytes, byte[] csrfBytes) {
130-
Assert.isTrue(randomBytes.length == csrfBytes.length, "arrays must be equal length");
131-
int len = csrfBytes.length;
132-
byte[] xoredCsrf = new byte[len];
133-
System.arraycopy(csrfBytes, 0, xoredCsrf, 0, len);
134-
for (int i = 0; i < len; i++) {
135-
xoredCsrf[i] ^= randomBytes[i];
136-
}
137-
return xoredCsrf;
80+
return csrfTokenEncoder.decode(actualToken, csrfToken.getToken());
13881
}
13982

14083
private static final class CachedCsrfTokenSupplier implements Supplier<CsrfToken> {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* Copyright 2004-present 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.csrf;
18+
19+
import org.junit.jupiter.api.BeforeEach;
20+
import org.junit.jupiter.api.Test;
21+
import org.springframework.mock.web.MockHttpServletRequest;
22+
23+
import static org.junit.jupiter.api.Assertions.assertEquals;
24+
import static org.junit.jupiter.api.Assertions.assertNotEquals;
25+
import static org.junit.jupiter.api.Assertions.assertNotNull;
26+
import static org.junit.jupiter.api.Assertions.assertNull;
27+
28+
/**
29+
* Tests for {@link XorCsrfTokenEncoder}.
30+
*
31+
* @author Cheol Jeon
32+
* @since
33+
*/
34+
public class XorCsrfTokenEncoderTest {
35+
36+
private XorCsrfTokenEncoder encoder;
37+
38+
private CsrfToken csrfToken;
39+
40+
@BeforeEach
41+
void setup() {
42+
this.encoder = new XorCsrfTokenEncoder();
43+
this.csrfToken = new CookieCsrfTokenRepository().generateToken(new MockHttpServletRequest());
44+
}
45+
46+
@Test
47+
void encodeAndDecode_shouldReturnOriginalToken() {
48+
String originalToken = csrfToken.getToken();
49+
50+
String encoded = encoder.encode(originalToken);
51+
assertNotNull(encoded, "Encoded token should not be null");
52+
53+
String decoded = encoder.decode(encoded, originalToken);
54+
assertEquals(originalToken, decoded, "Decoded token should match the original");
55+
}
56+
57+
@Test
58+
void decode_withInvalidBase64_shouldReturnNull() {
59+
String invalidEncoded = "not-base64!!";
60+
61+
String decoded = encoder.decode(invalidEncoded, "any-token");
62+
assertNull(decoded, "Decoding invalid base64 should return null");
63+
}
64+
65+
@Test
66+
void decode_withIncorrectLength_shouldReturnNull() {
67+
String originalToken = csrfToken.getToken();
68+
69+
String encoded = encoder.encode(originalToken);
70+
71+
// The CSRF token generated in Spring Security uses UUID.randomUUID().toString(),
72+
// which produces a 36‑byte ASCII string (hyphens + hex digits). Because 36 is
73+
// a multiple of 3, Base64 encoding of that input will not include padding ('=').
74+
// Therefore, removing a single character from the encoded string (encoded.length() - 1)
75+
// is sufficient here to simulate corruption of the token for this test case —
76+
// i.e. it will produce an encoded value that no longer decodes back to the original token.
77+
String truncated = encoded.substring(0, encoded.length() - 1);
78+
79+
String decoded = encoder.decode(truncated, originalToken);
80+
assertNull(decoded, "Decoding token with invalid length should return null");
81+
}
82+
83+
@Test
84+
void encode_shouldProduceDifferentValuesForSameInput() {
85+
String originalToken = csrfToken.getToken();
86+
87+
String encoded1 = encoder.encode(originalToken);
88+
String encoded2 = encoder.encode(originalToken);
89+
90+
// Because random bytes used, encoded results should differ
91+
assertNotEquals(encoded1, encoded2, "Encoded values for same input should differ");
92+
}
93+
}

0 commit comments

Comments
 (0)