Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit d143196

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.
1 parent f3761af commit d143196

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 SecureRandomsecureRandom = new SecureRandom();
43+
private CsrfTokenEncodercsrfTokenEncoder = 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 = newXorCsrfTokenEncoder(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)

AltStyle によって変換されたページ (->オリジナル) /