Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ snakeyaml = "1.33"
spotbugs = "4.7.3.4"
spring-framework = "5.3.39"
spring-jwt = "1.1.1.RELEASE"
spring-ldap = "2.4.2"
spring-ldap = "2.4.4"
spring-oauth2 = "2.5.2.RELEASE"
spring-security = "5.7.14"
spring-test-junit5 = "1.5.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.nio.charset.StandardCharsets;
import java.util.regex.Pattern;

@Component
Expand All @@ -34,6 +35,13 @@ public RegExpValidator(final SiteConfigPreferences preferences) {

@Override
public String isValid(final String password, final UserI user) {
// bcrypt only hashes the first 72 bytes of a password, and CVE-2025-22228 means anything beyond that is
// ignored on comparison as well: two passwords sharing a 72-byte prefix both authenticate. Reject rather
// than silently truncate, so a user is never given credentials whose tail does not count.
if (password != null && password.getBytes(StandardCharsets.UTF_8).length > MAX_PASSWORD_BYTES) {
return "Password must be " + MAX_PASSWORD_BYTES + " characters or fewer.";
}

final String regexp = getPasswordComplexity();
return StringUtils.isBlank(regexp) || Pattern.matches(regexp, password)
? ""
Expand All @@ -48,5 +56,11 @@ private String getPasswordComplexityMessage() {
return _preferences != null ? _preferences.getPasswordComplexityMessage() : "Password is not sufficiently complex.";
}

/**
* The maximum number of bytes bcrypt incorporates into a hash. Passwords longer than this are rejected: see
* CVE-2025-22228.
*/
private static final int MAX_PASSWORD_BYTES = 72;

private final SiteConfigPreferences _preferences;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* core: org.nrg.xdat.security.validators.RegExpValidatorTest
* XNAT http://www.xnat.org
* Copyright (c) 2005-2026, Washington University School of Medicine and Howard Hughes Medical Institute
* All Rights Reserved
*
* Released under the Simplified BSD.
*/

package org.nrg.xdat.security.validators;

import org.junit.Test;

import java.nio.charset.StandardCharsets;

import static org.assertj.core.api.Assertions.assertThat;
import static org.apache.commons.lang3.StringUtils.repeat;

/**
* Covers the password length ceiling added for CVE-2025-22228. The package-protected constructor gives the
* "panic mode" defaults, so the complexity pattern is {@code ^.*$} and length is the only thing under test.
*/
public class RegExpValidatorTest {
@Test
public void passwordAtTheByteLimitIsAccepted() {
assertThat(validator.isValid(repeat("a", 72), null)).isEmpty();
}

@Test
public void passwordOneByteOverTheLimitIsRejected() {
assertThat(validator.isValid(repeat("a", 73), null)).contains("72 characters or fewer");
}

@Test
public void multiByteCharactersCountAsBytesNotCharacters() {
// 25 three-byte characters is 75 bytes, so this is rejected despite being well under 72 characters.
final String password = repeat("中", 25);
assertThat(password.length()).isLessThan(72);
assertThat(password.getBytes(StandardCharsets.UTF_8).length).isGreaterThan(72);
assertThat(validator.isValid(password, null)).contains("72 characters or fewer");
}

@Test
public void ordinaryPasswordIsUnaffected() {
assertThat(validator.isValid("correct horse battery staple", null)).isEmpty();
}

private final RegExpValidator validator = new RegExpValidator();
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import org.nrg.xapi.exceptions.NoContentException;
import org.nrg.xapi.exceptions.NotAuthenticatedException;
import org.nrg.xapi.exceptions.NotFoundException;
import org.nrg.xapi.exceptions.XapiException;
import org.nrg.xapi.exceptions.ResourceAlreadyExistsException;
import org.nrg.xdat.XDAT;
import org.nrg.xdat.preferences.SiteConfigPreferences;
Expand Down Expand Up @@ -213,7 +214,18 @@ private ResponseEntity<?> getExceptionResponseEntity(@Nonnull final HttpServletR

private HttpStatus getExceptionResponseStatus(final Throwable throwable) {
final ResponseStatus annotation = AnnotationUtils.findAnnotation(throwable.getClass(), ResponseStatus.class);
return annotation != null ? annotation.value() : DEFAULT_ERROR_STATUS;
if (annotation != null) {
return annotation.value();
}
// XapiException carries its intended status as a field rather than an annotation. Without this, every
// `throw new XapiException(BAD_REQUEST, ...)` silently became a 500.
if (throwable instanceof XapiException) {
final HttpStatus status = ((XapiException) throwable).getStatus();
if (status != null) {
return status;
}
}
return DEFAULT_ERROR_STATUS;
}

private static final HttpStatus DEFAULT_ERROR_STATUS = INTERNAL_SERVER_ERROR;
Expand Down
5 changes: 5 additions & 0 deletions xnat-web/src/main/java/org/nrg/xapi/rest/users/UsersApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,11 @@ public User createUser(@RequestBody final User model) throws DataFormatException
}
}
return _factory.getUser(user);
} catch (PasswordComplexityException e) {
// Mirrors updateUser(): a rejected password is bad input, so it must not be rewrapped as
// UserInitException, which carries INTERNAL_SERVER_ERROR. DataFormatException is already declared
// here and carries BAD_REQUEST.
throw new DataFormatException(e.getMessage(), e);
} catch (Exception e) {
throw new UserInitException("Error occurred creating user " + user.getLogin() + " Cause: " + e.getMessage(), e);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
import org.springframework.security.authentication.AuthenticationEventPublisher;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.ObjectPostProcessor;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
Expand All @@ -68,6 +69,7 @@
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionFixationProtectionStrategy;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.header.HeaderWriterFilter;
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy;
import org.springframework.security.web.session.SimpleRedirectSessionInformationExpiredStrategy;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
Expand Down Expand Up @@ -307,6 +309,18 @@ protected void configure(final HttpSecurity http) throws Exception {
.sessionRegistry(sessionRegistry())
.expiredSessionStrategy(new SimpleRedirectSessionInformationExpiredStrategy("/app/template/Login.vm", redirectStrategy(_preferences, detector)));

// CVE-2026-22732: with shouldWriteHeadersEagerly left at its default of false, the headers configured
// below can go unwritten on responses that commit early, leaving requests without the content security
// policy, frame options and referrer policy. No public Spring Security 5.7.x release carries the fix, so
// apply the vendor's documented workaround.
http.headers().addObjectPostProcessor(new ObjectPostProcessor<HeaderWriterFilter>() {
@Override
public <O extends HeaderWriterFilter> O postProcess(final O filter) {
filter.setShouldWriteHeadersEagerly(true);
return filter;
}
});

http.headers().frameOptions().sameOrigin().cacheControl().disable().contentSecurityPolicy(CONTENT_SECURITY_POLICY)
.and().referrerPolicy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
.and().httpStrictTransportSecurity().disable()
Expand Down
Loading