diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index adc6344c2..1c14f2681 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" diff --git a/xdat/src/main/java/org/nrg/xdat/security/validators/RegExpValidator.java b/xdat/src/main/java/org/nrg/xdat/security/validators/RegExpValidator.java index e849b6f81..92bed4f3a 100644 --- a/xdat/src/main/java/org/nrg/xdat/security/validators/RegExpValidator.java +++ b/xdat/src/main/java/org/nrg/xdat/security/validators/RegExpValidator.java @@ -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 @@ -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) ? "" @@ -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; } diff --git a/xdat/src/test/java/org/nrg/xdat/security/validators/RegExpValidatorTest.java b/xdat/src/test/java/org/nrg/xdat/security/validators/RegExpValidatorTest.java new file mode 100644 index 000000000..4c7f8225d --- /dev/null +++ b/xdat/src/test/java/org/nrg/xdat/security/validators/RegExpValidatorTest.java @@ -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(); +} diff --git a/xnat-web/src/main/java/org/nrg/xapi/rest/XapiRestControllerAdvice.java b/xnat-web/src/main/java/org/nrg/xapi/rest/XapiRestControllerAdvice.java index e5bb14174..7838c111f 100644 --- a/xnat-web/src/main/java/org/nrg/xapi/rest/XapiRestControllerAdvice.java +++ b/xnat-web/src/main/java/org/nrg/xapi/rest/XapiRestControllerAdvice.java @@ -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; @@ -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; diff --git a/xnat-web/src/main/java/org/nrg/xapi/rest/users/UsersApi.java b/xnat-web/src/main/java/org/nrg/xapi/rest/users/UsersApi.java index a2fb892ff..5ee52204d 100644 --- a/xnat-web/src/main/java/org/nrg/xapi/rest/users/UsersApi.java +++ b/xnat-web/src/main/java/org/nrg/xapi/rest/users/UsersApi.java @@ -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); } diff --git a/xnat-web/src/main/java/org/nrg/xnat/initialization/SecurityConfig.java b/xnat-web/src/main/java/org/nrg/xnat/initialization/SecurityConfig.java index 5702c44c9..7435050da 100644 --- a/xnat-web/src/main/java/org/nrg/xnat/initialization/SecurityConfig.java +++ b/xnat-web/src/main/java/org/nrg/xnat/initialization/SecurityConfig.java @@ -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; @@ -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; @@ -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() { + @Override + public 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()