Skip to content

Commit 5a7578b

Browse files
committed
fix(security): address second-pass review (session DoS, NPE, log injection)
- SanitizingOAuth2AuthenticationFailureHandler: use getSession(false) so a failed OAuth2 callback does not allocate a session for sessionless callers (prevents unauthenticated scanners from forcing session creation). A real OAuth2 flow already has a session, so the user-facing message is preserved. - UserVerificationService.generateNewVerificationToken: guard against a null resolved token (unknown/consumed/malformed) and throw IllegalArgumentException instead of NPEing on updateToken. - TokenHasher.fingerprint: strip control chars (CR/LF) from the logged prefix to close CodeQL log-injection on token-derived log lines. - MfaFilterMergingConfiguration: convert tab indentation to 4 spaces per CLAUDE.md style. - Tests: model the real (session-present) OAuth2 callback and add a test asserting no session is created for a sessionless request.
1 parent 93ec181 commit 5a7578b

5 files changed

Lines changed: 78 additions & 35 deletions

File tree

src/main/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfiguration.java

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -39,36 +39,36 @@
3939
@ConditionalOnProperty(name = "user.mfa.enabled", havingValue = "true", matchIfMissing = false)
4040
public class MfaFilterMergingConfiguration {
4141

42-
/**
43-
* Replicates the behaviour of {@code @EnableMultiFactorAuthentication}'s internal {@code EnableMfaFiltersPostProcessor}
44-
* using only public API, by invoking the public {@link AbstractAuthenticationProcessingFilter#setMfaEnabled(boolean)} on
45-
* every authentication processing filter. Without this, completing a second factor would REPLACE the first factor's
46-
* authentication (dropping its authority) and the user could never satisfy all required factors (the H4 lockout).
47-
*
48-
* <p>
49-
* Declared {@code static} so the post-processor can be registered without eagerly instantiating this configuration
50-
* class. The bean exists only when {@code user.mfa.enabled=true}, so the default (no-MFA) login path is unaffected.
51-
* </p>
52-
*
53-
* @return a {@link BeanPostProcessor} that calls {@code setMfaEnabled(true)} on authentication processing filters
54-
*/
55-
@Bean
56-
public static BeanPostProcessor mfaFilterMergingPostProcessor() {
57-
return new BeanPostProcessor() {
58-
@Override
59-
public Object postProcessAfterInitialization(Object bean, String beanName) {
60-
// Intentionally scoped to AbstractAuthenticationProcessingFilter, which covers every authentication
61-
// mechanism this framework configures: formLogin, webAuthn, and oauth2Login all extend it. SS's internal
62-
// EnableMfaFiltersPostProcessor additionally flips the flag on AuthenticationFilter,
63-
// BasicAuthenticationFilter, and pre-authentication filters; this framework does not configure those
64-
// mechanisms, so they are deliberately not targeted here. See the class-level WARNING regarding
65-
// consumer-defined filters that extend this base class.
66-
if (bean instanceof AbstractAuthenticationProcessingFilter filter) {
67-
filter.setMfaEnabled(true);
68-
log.debug("MFA factor merging enabled on filter: {}", bean.getClass().getName());
69-
}
70-
return bean;
71-
}
72-
};
73-
}
42+
/**
43+
* Replicates the behaviour of {@code @EnableMultiFactorAuthentication}'s internal {@code EnableMfaFiltersPostProcessor}
44+
* using only public API, by invoking the public {@link AbstractAuthenticationProcessingFilter#setMfaEnabled(boolean)} on
45+
* every authentication processing filter. Without this, completing a second factor would REPLACE the first factor's
46+
* authentication (dropping its authority) and the user could never satisfy all required factors (the H4 lockout).
47+
*
48+
* <p>
49+
* Declared {@code static} so the post-processor can be registered without eagerly instantiating this configuration
50+
* class. The bean exists only when {@code user.mfa.enabled=true}, so the default (no-MFA) login path is unaffected.
51+
* </p>
52+
*
53+
* @return a {@link BeanPostProcessor} that calls {@code setMfaEnabled(true)} on authentication processing filters
54+
*/
55+
@Bean
56+
public static BeanPostProcessor mfaFilterMergingPostProcessor() {
57+
return new BeanPostProcessor() {
58+
@Override
59+
public Object postProcessAfterInitialization(Object bean, String beanName) {
60+
// Intentionally scoped to AbstractAuthenticationProcessingFilter, which covers every authentication
61+
// mechanism this framework configures: formLogin, webAuthn, and oauth2Login all extend it. SS's internal
62+
// EnableMfaFiltersPostProcessor additionally flips the flag on AuthenticationFilter,
63+
// BasicAuthenticationFilter, and pre-authentication filters; this framework does not configure those
64+
// mechanisms, so they are deliberately not targeted here. See the class-level WARNING regarding
65+
// consumer-defined filters that extend this base class.
66+
if (bean instanceof AbstractAuthenticationProcessingFilter filter) {
67+
filter.setMfaEnabled(true);
68+
log.debug("MFA factor merging enabled on filter: {}", bean.getClass().getName());
69+
}
70+
return bean;
71+
}
72+
};
73+
}
7474
}

src/main/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandler.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import jakarta.servlet.ServletException;
99
import jakarta.servlet.http.HttpServletRequest;
1010
import jakarta.servlet.http.HttpServletResponse;
11+
import jakarta.servlet.http.HttpSession;
1112
import lombok.extern.slf4j.Slf4j;
1213

1314
/**
@@ -71,7 +72,14 @@ public void onAuthenticationFailure(HttpServletRequest request, HttpServletRespo
7172
log.debug("OAuth2 login failure detail", exception);
7273

7374
// Store ONLY a generic, non-sensitive message for the UI. Never the raw exception message.
74-
request.getSession().setAttribute(ERROR_MESSAGE_SESSION_ATTRIBUTE, resolveUserFacingMessage(exception));
75+
// Use getSession(false) so we do not allocate a session for callers that do not already have one:
76+
// a legitimate OAuth2 attempt establishes a session during the authorization-request phase, while an
77+
// unauthenticated scanner hitting the callback cold has none and should not be able to force session
78+
// creation. When there is no session the login page simply shows its default message.
79+
HttpSession session = request.getSession(false);
80+
if (session != null) {
81+
session.setAttribute(ERROR_MESSAGE_SESSION_ATTRIBUTE, resolveUserFacingMessage(exception));
82+
}
7583
response.sendRedirect(loginPageURI);
7684
}
7785

src/main/java/com/digitalsanctuary/spring/user/service/TokenHasher.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,14 @@ public String hash(final String rawToken) {
9999
* (followed by an ellipsis) for longer tokens. Intended purely for correlating log lines, not for
100100
* any security decision.
101101
*
102+
* <p>
103+
* The returned prefix is stripped of CR/LF and other control characters so that an attacker-supplied
104+
* token (these values arrive as request parameters) cannot forge or split log lines (log injection).
105+
* </p>
106+
*
102107
* @param token the raw token (may be {@code null})
103108
* @return {@code "null"} if the token is {@code null}, {@code "****"} if it is 8 characters or fewer,
104-
* otherwise the first 6 characters followed by an ellipsis
109+
* otherwise the first 6 (control-character-stripped) characters followed by an ellipsis
105110
*/
106111
public static String fingerprint(final String token) {
107112
if (token == null) {
@@ -110,7 +115,9 @@ public static String fingerprint(final String token) {
110115
if (token.length() <= 8) {
111116
return "****";
112117
}
113-
return token.substring(0, 6) + "…";
118+
// Strip control characters (incl. CR/LF) from the logged prefix to prevent log injection/forging.
119+
final String prefix = token.substring(0, 6).replaceAll("\\p{Cntrl}", "");
120+
return prefix + "…";
114121
}
115122

116123
/**

src/main/java/com/digitalsanctuary/spring/user/service/UserVerificationService.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,13 @@ public VerificationToken getVerificationToken(final String verificationToken) {
115115
@Transactional
116116
public VerificationToken generateNewVerificationToken(final String existingVerificationToken) {
117117
VerificationToken vToken = resolveByRawToken(existingVerificationToken);
118+
if (vToken == null) {
119+
// The supplied token does not resolve to a stored row (unknown, already consumed, or malformed).
120+
// Fail explicitly rather than NPE on the update below.
121+
log.warn("UserVerificationService.generateNewVerificationToken: no token found for {}",
122+
TokenHasher.fingerprint(existingVerificationToken));
123+
throw new IllegalArgumentException("No verification token found for the supplied value");
124+
}
118125
final String rawToken = UUID.randomUUID().toString();
119126
// Store the hash of the new raw token; the raw value is what gets emailed to the user.
120127
vToken.updateToken(tokenHasher.hash(rawToken), verificationTokenValidityMinutes);

src/test/java/com/digitalsanctuary/spring/user/security/SanitizingOAuth2AuthenticationFailureHandlerTest.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ void setUp() {
3333
void shouldNotLeakRawMessageForLockedException() throws Exception {
3434
// Given - a LockedException whose message contains the account email (per Task 1.4)
3535
MockHttpServletRequest request = new MockHttpServletRequest();
36+
// A real OAuth2 callback already has a session (the authorization request is stored there during the
37+
// redirect phase), so the handler stores the user-facing message on the existing session.
38+
request.getSession(true);
3639
MockHttpServletResponse response = new MockHttpServletResponse();
3740
LockedException raw = new LockedException("Account is locked for user secret.email@example.com");
3841

@@ -55,6 +58,7 @@ void shouldNotLeakRawMessageForLockedException() throws Exception {
5558
void shouldNotLeakRawMessageForOAuth2Exception() throws Exception {
5659
// Given
5760
MockHttpServletRequest request = new MockHttpServletRequest();
61+
request.getSession(true);
5862
MockHttpServletResponse response = new MockHttpServletResponse();
5963
OAuth2AuthenticationException raw = new OAuth2AuthenticationException(
6064
new OAuth2Error("User Registered With Alternate Provider"),
@@ -75,6 +79,7 @@ void shouldNotLeakRawMessageForOAuth2Exception() throws Exception {
7579
void shouldMapEmailNotVerifiedToSpecificGenericMessage() throws Exception {
7680
// Given
7781
MockHttpServletRequest request = new MockHttpServletRequest();
82+
request.getSession(true);
7883
MockHttpServletResponse response = new MockHttpServletResponse();
7984
OAuth2AuthenticationException raw = new OAuth2AuthenticationException(
8085
new OAuth2Error("email_not_verified"), "Email verified=false for victim@example.com");
@@ -88,4 +93,20 @@ void shouldMapEmailNotVerifiedToSpecificGenericMessage() throws Exception {
8893
assertThat(stored).isEqualTo(SanitizingOAuth2AuthenticationFailureHandler.EMAIL_NOT_VERIFIED_MESSAGE);
8994
assertThat(response.getRedirectedUrl()).isEqualTo(LOGIN_PAGE_URI);
9095
}
96+
97+
@Test
98+
@DisplayName("Should NOT allocate a session for a sessionless request (no session forced by scanners)")
99+
void shouldNotCreateSessionWhenNonePresent() throws Exception {
100+
// Given - a cold request with no existing session (e.g. an unauthenticated scanner hitting the callback)
101+
MockHttpServletRequest request = new MockHttpServletRequest();
102+
MockHttpServletResponse response = new MockHttpServletResponse();
103+
LockedException raw = new LockedException("Account is locked for user secret.email@example.com");
104+
105+
// When
106+
handler.onAuthenticationFailure(request, response, raw);
107+
108+
// Then - no session is created (the handler uses getSession(false)) and the redirect still happens.
109+
assertThat(request.getSession(false)).isNull();
110+
assertThat(response.getRedirectedUrl()).isEqualTo(LOGIN_PAGE_URI);
111+
}
91112
}

0 commit comments

Comments
 (0)