From d1ce69ca996521026d336ecf94a7834fbc5e7da7 Mon Sep 17 00:00:00 2001 From: Andrey Litvitski Date: Wed, 18 Feb 2026 22:20:35 +0300 Subject: [PATCH] Specify charset in WWW-Authenticate for Basic Auth In this commit, we add support for the charset from RFC-7617, which definitely solves the problem when the client does not know what charset we are parsing with. Closes: gh-18755 Signed-off-by: Andrey Litvitski --- .../web/builders/NamespaceHttpTests.java | 2 +- .../configurers/HttpBasicConfigurerTests.java | 4 +-- .../configurers/NamespaceHttpBasicTests.java | 8 ++--- .../config/http/NamespaceHttpBasicTests.java | 2 +- .../annotation/web/HttpBasicDslTests.kt | 4 +-- .../www/BasicAuthenticationEntryPoint.java | 24 ++++++++++++++- .../BasicAuthenticationEntryPointTests.java | 29 +++++++++++++++++-- 7 files changed, 60 insertions(+), 13 deletions(-) diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/builders/NamespaceHttpTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/builders/NamespaceHttpTests.java index f8e327ab4d..e273f15607 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/builders/NamespaceHttpTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/builders/NamespaceHttpTests.java @@ -197,7 +197,7 @@ public class NamespaceHttpTests { // @formatter:off this.mockMvc.perform(get("/")) .andExpect(status().isUnauthorized()) - .andExpect(header().string("WWW-Authenticate", "Basic realm=\"RealmConfig\"")); + .andExpect(header().string("WWW-Authenticate", "Basic realm=\"RealmConfig\", charset=\"UTF-8\"")); // @formatter:on } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurerTests.java index 7931773cda..f182287366 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurerTests.java @@ -103,7 +103,7 @@ public class HttpBasicConfigurerTests { // @formatter:off this.mvc.perform(get("/")) .andExpect(status().isUnauthorized()) - .andExpect(header().string("WWW-Authenticate", "Basic realm=\"Realm\"")); + .andExpect(header().string("WWW-Authenticate", "Basic realm=\"Realm\", charset=\"UTF-8\"")); // @formatter:on } @@ -114,7 +114,7 @@ public class HttpBasicConfigurerTests { // @formatter:off this.mvc.perform(get("/")) .andExpect(status().isUnauthorized()) - .andExpect(header().string("WWW-Authenticate", "Basic realm=\"Realm\"")); + .andExpect(header().string("WWW-Authenticate", "Basic realm=\"Realm\", charset=\"UTF-8\"")); // @formatter:on } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/NamespaceHttpBasicTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/NamespaceHttpBasicTests.java index 17fa75980e..91eb952a24 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/NamespaceHttpBasicTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/NamespaceHttpBasicTests.java @@ -71,7 +71,7 @@ public class NamespaceHttpBasicTests { // @formatter:off this.mvc.perform(requestWithInvalidPassword) .andExpect(status().isUnauthorized()) - .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"Realm\"")); + .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"Realm\", charset=\"UTF-8\"")); // @formatter:on MockHttpServletRequestBuilder requestWithValidPassword = get("/").with(httpBasic("user", "password")); this.mvc.perform(requestWithValidPassword).andExpect(status().isNotFound()); @@ -85,7 +85,7 @@ public class NamespaceHttpBasicTests { // @formatter:off this.mvc.perform(requestWithInvalidPassword) .andExpect(status().isUnauthorized()) - .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"Realm\"")); + .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"Realm\", charset=\"UTF-8\"")); // @formatter:on MockHttpServletRequestBuilder requestWithValidPassword = get("/").with(httpBasic("user", "password")); this.mvc.perform(requestWithValidPassword).andExpect(status().isNotFound()); @@ -101,7 +101,7 @@ public class NamespaceHttpBasicTests { // @formatter:off this.mvc.perform(requestWithInvalidPassword) .andExpect(status().isUnauthorized()) - .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"Custom Realm\"")); + .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"Custom Realm\", charset=\"UTF-8\"")); // @formatter:on } @@ -112,7 +112,7 @@ public class NamespaceHttpBasicTests { // @formatter:off this.mvc.perform(requestWithInvalidPassword) .andExpect(status().isUnauthorized()) - .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"Custom Realm\"")); + .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"Custom Realm\", charset=\"UTF-8\"")); // @formatter:on } diff --git a/config/src/test/java/org/springframework/security/config/http/NamespaceHttpBasicTests.java b/config/src/test/java/org/springframework/security/config/http/NamespaceHttpBasicTests.java index 2064631ae8..c8e3c9f733 100644 --- a/config/src/test/java/org/springframework/security/config/http/NamespaceHttpBasicTests.java +++ b/config/src/test/java/org/springframework/security/config/http/NamespaceHttpBasicTests.java @@ -133,7 +133,7 @@ public class NamespaceHttpBasicTests { // @formatter:on this.springSecurityFilterChain.doFilter(this.request, this.response, this.chain); assertThat(this.response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); - assertThat(this.response.getHeader("WWW-Authenticate")).isEqualTo("Basic realm=\"Realm\""); + assertThat(this.response.getHeader("WWW-Authenticate")).isEqualTo("Basic realm=\"Realm\", charset=\"UTF-8\""); } private void loadContext(String context) { diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/HttpBasicDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/HttpBasicDslTests.kt index 0aaa84985e..2df330ac12 100644 --- a/config/src/test/kotlin/org/springframework/security/config/annotation/web/HttpBasicDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/HttpBasicDslTests.kt @@ -74,7 +74,7 @@ class HttpBasicDslTests { this.mockMvc.get("/") .andExpect { - header { string("WWW-Authenticate", "Basic realm=\"Realm\"") } + header { string("WWW-Authenticate", "Basic realm=\"Realm\", charset=\"UTF-8\"") } } } @@ -110,7 +110,7 @@ class HttpBasicDslTests { this.mockMvc.get("/") .andExpect { - header { string("WWW-Authenticate", "Basic realm=\"Custom Realm\"") } + header { string("WWW-Authenticate", "Basic realm=\"Custom Realm\", charset=\"UTF-8\"") } } } diff --git a/web/src/main/java/org/springframework/security/web/authentication/www/BasicAuthenticationEntryPoint.java b/web/src/main/java/org/springframework/security/web/authentication/www/BasicAuthenticationEntryPoint.java index fb81f0e591..44eacab8aa 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/www/BasicAuthenticationEntryPoint.java +++ b/web/src/main/java/org/springframework/security/web/authentication/www/BasicAuthenticationEntryPoint.java @@ -17,6 +17,8 @@ package org.springframework.security.web.authentication.www; import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -40,11 +42,14 @@ import org.springframework.util.Assert; * authorized, causing it to prompt the user to login again. * * @author Ben Alex + * @author Andrey Litvitski */ public class BasicAuthenticationEntryPoint implements AuthenticationEntryPoint, InitializingBean { private @Nullable String realmName; + private @Nullable Charset charset = StandardCharsets.UTF_8; + @Override public void afterPropertiesSet() { Assert.hasText(this.realmName, "realmName must be specified"); @@ -53,7 +58,11 @@ public class BasicAuthenticationEntryPoint implements AuthenticationEntryPoint, @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { - response.setHeader("WWW-Authenticate", "Basic realm=\"" + this.realmName + "\""); + String header = "Basic realm=\"" + this.realmName + "\""; + if (this.charset != null) { + header += ", charset=\"" + this.charset.name() + "\""; + } + response.setHeader("WWW-Authenticate", header); response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase()); } @@ -65,4 +74,17 @@ public class BasicAuthenticationEntryPoint implements AuthenticationEntryPoint, this.realmName = realmName; } + /** + * Sets the charset to include in the {@code WWW-Authenticate} response header. By + * default, it is set to {@link StandardCharsets#UTF_8}. Set to {@code null} to omit + * the charset attribute from the header. As per RFC 7617, only UTF-8 is permitted. + * @param charset the charset to use ({@link StandardCharsets#UTF_8} is the only + * accepted value), or {@code null} to remove the charset attribute + */ + public void setCharset(@Nullable Charset charset) { + Assert.isTrue(charset == null || StandardCharsets.UTF_8.equals(charset), + "RFC 7617 only permits UTF-8 as the charset for Basic authentication"); + this.charset = charset; + } + } diff --git a/web/src/test/java/org/springframework/security/web/authentication/www/BasicAuthenticationEntryPointTests.java b/web/src/test/java/org/springframework/security/web/authentication/www/BasicAuthenticationEntryPointTests.java index 7cf1c720a6..5811589652 100644 --- a/web/src/test/java/org/springframework/security/web/authentication/www/BasicAuthenticationEntryPointTests.java +++ b/web/src/test/java/org/springframework/security/web/authentication/www/BasicAuthenticationEntryPointTests.java @@ -25,6 +25,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.DisabledException; import static org.assertj.core.api.Assertions.assertThat; @@ -34,6 +35,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException * Tests {@link BasicAuthenticationEntryPoint}. * * @author Ben Alex + * @author Andrey Litvitski */ public class BasicAuthenticationEntryPointTests { @@ -62,7 +64,7 @@ public class BasicAuthenticationEntryPointTests { ep.commence(request, response, new DisabledException("These are the jokes kid")); assertThat(response.getStatus()).isEqualTo(401); assertThat(response.getErrorMessage()).isEqualTo(HttpStatus.UNAUTHORIZED.getReasonPhrase()); - assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Basic realm=\"hello\""); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Basic realm=\"hello\", charset=\"UTF-8\""); } // gh-13737 @@ -77,7 +79,30 @@ public class BasicAuthenticationEntryPointTests { ep.commence(request, response, new DisabledException("Disabled")); List headers = response.getHeaders("WWW-Authenticate"); assertThat(headers).hasSize(1); - assertThat(headers.get(0)).isEqualTo("Basic realm=\"hello\""); + assertThat(headers.get(0)).isEqualTo("Basic realm=\"hello\", charset=\"UTF-8\""); + } + + @Test + void commenceWhenDefaultThenIncludesUtf8Charset() throws Exception { + BasicAuthenticationEntryPoint entryPoint = new BasicAuthenticationEntryPoint(); + entryPoint.setRealmName("TestRealm"); + entryPoint.afterPropertiesSet(); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + entryPoint.commence(request, response, new BadCredentialsException("test")); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Basic realm=\"TestRealm\", charset=\"UTF-8\""); + } + + @Test + void commenceWhenCharsetIsNullThenOmitsCharset() throws Exception { + BasicAuthenticationEntryPoint entryPoint = new BasicAuthenticationEntryPoint(); + entryPoint.setRealmName("TestRealm"); + entryPoint.setCharset(null); + entryPoint.afterPropertiesSet(); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + entryPoint.commence(request, response, new BadCredentialsException("test")); + assertThat(response.getHeader("WWW-Authenticate")).isEqualTo("Basic realm=\"TestRealm\""); } }