From 734bf005b5246ac449c3525a681a6e42c1069a38 Mon Sep 17 00:00:00 2001 From: Thierry Boileau Date: Sat, 31 Oct 2026 21:21:36 +0100 Subject: [PATCH 1/4] HTTP Basic: opt-in UTF-8 credentials (RFC 7617), read token68 challenges - New opt-in, disabled by default: with the system property org.restlet.engine.security.basic.utf8=true a Basic challenge advertises charset="UTF-8", a server decodes the credentials as UTF-8 (falling back to ISO-8859-1) and a client answering such a challenge encodes UTF-8. Issue #1455. - A WWW-Authenticate challenge made of a token68 ending with "=" (Negotiate) was dropped with the whole header. Issue #191. Co-Authored-By: Claude Sonnet 5.5 --- changes.md | 2 + .../engine/header/ChallengeRequestReader.java | 51 +++++ .../engine/security/AuthenticatorUtils.java | 9 + .../engine/security/HttpBasicHelper.java | 53 ++++- .../security/ChallengeAuthenticator.java | 10 +- .../ChallengeRequestReaderTestCase.java | 41 ++++ .../security/AuthenticatorUtilsTestCase.java | 16 ++ .../security/HttpBasicHelperTestCase.java | 187 ++++++++++++++++++ .../ChallengeAuthenticatorTestCase.java | 40 ++++ .../restlet/security/HttpBasicTestCase.java | 49 +++++ 10 files changed, 454 insertions(+), 4 deletions(-) create mode 100644 org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java diff --git a/changes.md b/changes.md index 1125222843..0fe8fffb65 100644 --- a/changes.md +++ b/changes.md @@ -9,12 +9,14 @@ Changes log - Deprecated the legacy RFC 2109/2965 cookie API (cookie version, comment and related constructors), to be removed in Restlet 3.0 in favor of RFC 6265. - Added an optional origin matcher to CORS (`CorsService`, `CorsFilter`, `setAllowedOriginMatcher(Predicate)`) to accept origins that are not listed, for example any sub-domain. The response always contains the exact request origin; it has no effect while the allowed origins contain `*`. Issue #1611. - CorsService and CorsFilter now log a warning when `*` is allowed as origin together with credentials (any web site could then send credentialed requests), and their Javadoc references the Fetch standard instead of the W3C CORS draft. Issue #1611. + - Added an opt-in UTF-8 handling of the HTTP Basic credentials (RFC 7617), enabled with the `org.restlet.engine.security.basic.utf8` system property (`HttpBasicHelper.UTF8_PROPERTY`): a Basic challenge then advertises `charset="UTF-8"`, a server decodes the credentials as UTF-8 (falling back to ISO-8859-1 if they are not valid UTF-8), and a client answering such a challenge encodes its credentials in UTF-8. Disabled by default (ISO-8859-1). Issue #1455. - Bugs fixed - Slf4j bridge reports wrong line number and caller class in log4j. Issue #233. - When GET is an allowed method of a server resource, HEAD is now also listed (e.g. in the Allow header of a 405 response). Issue #266. - The Max-Age and Path attributes of a version 1 cookie are no longer quoted in the Set-Cookie header, as browsers ignore them otherwise. Issue #1334. - CookieAuthenticator no longer lets the next restlet overwrite the redirection sent after a login. Issue #554. - CorsService and CorsFilter now add `Vary: Origin` to every response when the CORS answer depends on the request origin (origin allow-list or credentials allowed), including requests without an `Origin` header, so shared caches no longer serve the response of one origin to another. A static `*` without credentials still sends no `Vary`, as recommended by the Fetch standard. Issue #1611. + - Fixed the parsing of a `WWW-Authenticate` challenge made of a token68 ending with `=` (such as a `Negotiate` token): the whole header was dropped. Issue #191. - 2.7.0 (30-09-2026) - Misc diff --git a/org.restlet/src/main/java/org/restlet/engine/header/ChallengeRequestReader.java b/org.restlet/src/main/java/org/restlet/engine/header/ChallengeRequestReader.java index 1783589cd6..736d99579f 100644 --- a/org.restlet/src/main/java/org/restlet/engine/header/ChallengeRequestReader.java +++ b/org.restlet/src/main/java/org/restlet/engine/header/ChallengeRequestReader.java @@ -54,6 +54,13 @@ public HeaderWriter append(Parameter value) { } }; + // A challenge may carry a single token68 instead of parameters + // (RFC 9110, section 11.2), such as a base64 Negotiate token. + String token68 = readToken68(); + if (token68 != null) { + w.append(token68); + } + boolean stop = false; while (peek() != -1 && !stop) { boolean sepSkipped = skipValueSeparator(); @@ -82,4 +89,48 @@ public HeaderWriter append(Parameter value) { return result; } + + /** + * Reads a token68 ending with at least one "=" padding character, if the next characters are + * one. A token68 without padding is read as a regular parameter, as before. + * + * @return The token68, or null (nothing consumed) if the next characters are not one. + */ + private String readToken68() { + mark(); + StringBuilder token = new StringBuilder(); + int next = peek(); + while (next != -1 && isToken68Char(next)) { + token.append((char) read()); + next = peek(); + } + + int padding = 0; + while (next == '=') { + token.append((char) read()); + padding++; + next = peek(); + } + + if (token.length() > padding + && padding > 0 + && (next == -1 || next == ',' || isSpace(next))) { + return token.toString(); + } + + reset(); + return null; + } + + private static boolean isToken68Char(int c) { + return (c >= 'A' && c <= 'Z') + || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') + || c == '-' + || c == '.' + || c == '_' + || c == '~' + || c == '+' + || c == '/'; + } } diff --git a/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java b/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java index ab7db1abe7..5e2f055103 100644 --- a/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java +++ b/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java @@ -364,6 +364,15 @@ public static void update( realm = challengeRequest.getRealm(); nonce = challengeRequest.getServerNonce(); challengeResponse.setOpaque(challengeRequest.getOpaque()); + + // Credentials charset advertised by the server (RFC 7617, section 2.1) + String charset = challengeRequest.getParameters().getFirstValue("charset"); + if (HttpBasicHelper.isUtf8Enabled() + && ChallengeScheme.HTTP_BASIC.equals(challengeResponse.getScheme()) + && charset != null + && challengeResponse.getParameters().getFirstValue("charset") == null) { + challengeResponse.getParameters().add("charset", charset); + } } challengeResponse.setRealm(realm); diff --git a/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java b/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java index 8017d26d27..71df60fbbf 100644 --- a/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java +++ b/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java @@ -11,6 +11,10 @@ import java.io.CharArrayWriter; import java.io.IOException; import java.io.UnsupportedEncodingException; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.logging.Level; import org.restlet.Context; @@ -33,11 +37,31 @@ */ public class HttpBasicHelper extends AuthenticatorHelper { + /** + * Name of the system property that enables the UTF-8 handling of the HTTP Basic credentials + * (RFC 7617, section 2.1). Disabled by default, to keep the historical ISO-8859-1 behavior. + * When set to "true": a server challenge advertises {@code charset="UTF-8"}, credentials + * received by a server are decoded as UTF-8 (falling back to ISO-8859-1 if they are not valid + * UTF-8) and a client answering a challenge that advertises UTF-8 encodes its credentials in + * UTF-8. + */ + public static final String UTF8_PROPERTY = "org.restlet.engine.security.basic.utf8"; + private static final String CHARSET = "charset"; private static final String REALM = "realm"; private static final String UTF_8 = "UTF-8"; private static final String ISO_8859_1 = "ISO-8859-1"; + /** + * Indicates if the UTF-8 handling of the HTTP Basic credentials is enabled. + * + * @return True if the {@link #UTF8_PROPERTY} system property is "true". + * @see #UTF8_PROPERTY + */ + public static boolean isUtf8Enabled() { + return Boolean.getBoolean(UTF8_PROPERTY); + } + /** Constructor. */ public HttpBasicHelper() { super(ChallengeScheme.HTTP_BASIC, true, true); @@ -152,11 +176,12 @@ public void parseResponse( } try { - String charset = getCharset(challenge); - byte[] credentialsEncoded = Base64.getDecoder().decode(challenge.getRawValue()); - String credentials = new String(credentialsEncoded, charset); + String credentials = + isUtf8Enabled() + ? decode(credentialsEncoded) + : new String(credentialsEncoded, getCharset(challenge)); int separator = credentials.indexOf(':'); if (separator == -1) { @@ -177,6 +202,28 @@ public void parseResponse( } } + /** + * Decodes the credentials sent by a client. The server advertises {@code charset="UTF-8"} (RFC + * 7617 section 2.1) but the header does not tell which charset the client used, so the bytes + * are read as UTF-8 first and, if they are not valid UTF-8, as ISO-8859-1, the historical + * default. + * + * @param bytes The decoded Base64 credentials. + * @return The "user-id:password" string. + */ + private static String decode(byte[] bytes) { + try { + return StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + } catch (CharacterCodingException e) { + return new String(bytes, StandardCharsets.ISO_8859_1); + } + } + /** * Returns the charset from the given ChallengeResponse. If the charset is not specified, or if * it is not UTF-8, then ISO-8859-1 is returned as per RFC 7617. diff --git a/org.restlet/src/main/java/org/restlet/security/ChallengeAuthenticator.java b/org.restlet/src/main/java/org/restlet/security/ChallengeAuthenticator.java index 8f9a9fd176..7d5b4a87c7 100644 --- a/org.restlet/src/main/java/org/restlet/security/ChallengeAuthenticator.java +++ b/org.restlet/src/main/java/org/restlet/security/ChallengeAuthenticator.java @@ -17,6 +17,7 @@ import org.restlet.data.ChallengeScheme; import org.restlet.data.ClientInfo; import org.restlet.data.Status; +import org.restlet.engine.security.HttpBasicHelper; /** * Authenticator based on a challenge scheme. This is typically used to support the HTTP BASIC and @@ -222,7 +223,14 @@ public void challenge(Response response, boolean stale) { * @return A new challenge request. */ protected ChallengeRequest createChallengeRequest(boolean stale) { - return new ChallengeRequest(getScheme(), getRealm()); + ChallengeRequest result = new ChallengeRequest(getScheme(), getRealm()); + + if (ChallengeScheme.HTTP_BASIC.equals(getScheme()) && HttpBasicHelper.isUtf8Enabled()) { + // RFC 7617 section 2.1: credentials are expected in UTF-8 + result.getParameters().add("charset", "UTF-8"); + } + + return result; } /** diff --git a/org.restlet/src/test/java/org/restlet/engine/header/ChallengeRequestReaderTestCase.java b/org.restlet/src/test/java/org/restlet/engine/header/ChallengeRequestReaderTestCase.java index 6737670f73..03beefe3ae 100644 --- a/org.restlet/src/test/java/org/restlet/engine/header/ChallengeRequestReaderTestCase.java +++ b/org.restlet/src/test/java/org/restlet/engine/header/ChallengeRequestReaderTestCase.java @@ -13,6 +13,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; +import java.util.List; import org.junit.jupiter.api.Test; import org.restlet.data.ChallengeRequest; @@ -38,4 +39,44 @@ void readValue_emptyHeader_returnsNull() throws IOException { ChallengeRequestReader reader = new ChallengeRequestReader(""); assertNull(reader.readValue()); } + + @Test + void readValues_twoChallenges_returnsBoth() throws IOException { + List result = + new ChallengeRequestReader("Basic realm=\"a\", Digest realm=\"b\", nonce=\"x\"") + .readValues(); + assertEquals(2, result.size()); + assertEquals("Basic", result.get(0).getScheme().getTechnicalName()); + assertEquals("realm=a", result.get(0).getRawValue()); + assertEquals("Digest", result.get(1).getScheme().getTechnicalName()); + assertEquals("realm=b, nonce=x", result.get(1).getRawValue()); + } + + @Test + void readValues_schemeWithoutParametersThenChallenge_returnsBoth() throws IOException { + List result = + new ChallengeRequestReader("Negotiate, Basic realm=\"a\"").readValues(); + assertEquals(2, result.size()); + assertEquals("Negotiate", result.get(0).getScheme().getTechnicalName()); + assertEquals("Basic", result.get(1).getScheme().getTechnicalName()); + } + + @Test + void readValues_commaInsideQuotedValue_staysInOneChallenge() throws IOException { + List result = + new ChallengeRequestReader("Basic realm=\"a, Digest b\"").readValues(); + assertEquals(1, result.size()); + assertEquals("Basic", result.get(0).getScheme().getTechnicalName()); + } + + /** RFC 9110 section 11.2: token68 may end with "=" padding, as Negotiate tokens do. */ + @Test + void readValues_token68Challenge_keepsToken() throws IOException { + List result = + new ChallengeRequestReader("Negotiate YIIabc==, Basic realm=\"a\"").readValues(); + assertEquals(2, result.size()); + assertEquals("Negotiate", result.get(0).getScheme().getTechnicalName()); + assertTrue(result.get(0).getRawValue().contains("YIIabc")); + assertEquals("Basic", result.get(1).getScheme().getTechnicalName()); + } } diff --git a/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsTestCase.java b/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsTestCase.java index 99a005aac4..1d0af63513 100644 --- a/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsTestCase.java +++ b/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsTestCase.java @@ -13,6 +13,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.List; import org.junit.jupiter.api.Test; import org.restlet.Request; import org.restlet.Response; @@ -194,4 +195,19 @@ void updateReference_rawValuePresent_returnsUnchangedReference() { AuthenticatorUtils.updateReference( request.getResourceRef(), challengeResponse, request)); } + + @Test + void parseRequest_twoChallenges_eachGetsItsSchemeAndRealm() { + List result = + AuthenticatorUtils.parseRequest( + new Response(new Request(Method.GET, "http://localhost/")), + "Basic realm=\"my realm\", Bearer error=\"x\"", + null); + + assertEquals(2, result.size()); + assertEquals(ChallengeScheme.HTTP_BASIC, result.get(0).getScheme()); + assertEquals("my realm", result.get(0).getRealm()); + assertEquals("Bearer", result.get(1).getScheme().getTechnicalName()); + assertEquals("error=x", result.get(1).getRawValue()); + } } diff --git a/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java b/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java new file mode 100644 index 0000000000..718c07fce4 --- /dev/null +++ b/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java @@ -0,0 +1,187 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.engine.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.data.ChallengeRequest; +import org.restlet.data.ChallengeResponse; +import org.restlet.data.ChallengeScheme; +import org.restlet.data.Method; +import org.restlet.data.Parameter; + +/** + * Unit tests for the credentials charset handling of {@link HttpBasicHelper} (RFC 7617 section + * 2.1), without any network. + */ +class HttpBasicHelperTestCase { + + @BeforeEach + void enableUtf8() { + System.setProperty(HttpBasicHelper.UTF8_PROPERTY, "true"); + } + + @AfterEach + void restoreDefault() { + System.clearProperty(HttpBasicHelper.UTF8_PROPERTY); + } + + private static final String LATIN1_LOGIN = "josé"; + private static final String LATIN1_PASSWORD = "pässwörd"; + private static final String UTF8_LOGIN = "zoë"; + + /** The euro sign does not exist in ISO-8859-1. */ + private static final String UTF8_PASSWORD = "p€ss"; + + private static String basicHeader(String login, String password, java.nio.charset.Charset cs) { + byte[] bytes = (login + ":" + password).getBytes(cs); + return "Basic " + Base64.getEncoder().encodeToString(bytes); + } + + private static String base64Of(String login, String password, java.nio.charset.Charset cs) { + return Base64.getEncoder().encodeToString((login + ":" + password).getBytes(cs)); + } + + // ---- Server side: decoding the Authorization header -------------------------------------- + + @Test + void parseResponse_asciiCredentials_decoded() { + ChallengeResponse cr = + AuthenticatorUtils.parseResponse( + new Request(Method.GET, "http://localhost/"), + basicHeader("user", "secret", StandardCharsets.UTF_8), + null); + assertEquals("user", cr.getIdentifier()); + assertEquals("secret", String.valueOf(cr.getSecret())); + } + + /** Backward compatibility: bytes that are not valid UTF-8 are still read as ISO-8859-1. */ + @Test + void parseResponse_latin1Bytes_decodedAsIso88591() { + ChallengeResponse cr = + AuthenticatorUtils.parseResponse( + new Request(Method.GET, "http://localhost/"), + basicHeader(LATIN1_LOGIN, LATIN1_PASSWORD, StandardCharsets.ISO_8859_1), + null); + assertEquals(LATIN1_LOGIN, cr.getIdentifier()); + assertEquals(LATIN1_PASSWORD, String.valueOf(cr.getSecret())); + } + + /** Issue #1455: the server advertises UTF-8 but decodes ISO-8859-1. */ + @Test + void parseResponse_utf8Bytes_decodedAsUtf8() { + ChallengeResponse cr = + AuthenticatorUtils.parseResponse( + new Request(Method.GET, "http://localhost/"), + basicHeader(UTF8_LOGIN, UTF8_PASSWORD, StandardCharsets.UTF_8), + null); + assertEquals(UTF8_LOGIN, cr.getIdentifier()); + assertEquals(UTF8_PASSWORD, String.valueOf(cr.getSecret())); + } + + /** The default is unchanged: without the opt-in, credentials are read as ISO-8859-1. */ + @Test + void parseResponse_utf8Bytes_utf8Disabled_decodedAsIso88591() { + System.clearProperty(HttpBasicHelper.UTF8_PROPERTY); + ChallengeResponse cr = + AuthenticatorUtils.parseResponse( + new Request(Method.GET, "http://localhost/"), + basicHeader("zoë", "pw", StandardCharsets.UTF_8), + null); + assertEquals("zoë", cr.getIdentifier()); + } + + @Test + void formatResponse_challengeAdvertisedUtf8_utf8Disabled_encodesIso88591() { + System.clearProperty(HttpBasicHelper.UTF8_PROPERTY); + Response response = new Response(new Request(Method.GET, "http://localhost/")); + ChallengeRequest challenge = new ChallengeRequest(ChallengeScheme.HTTP_BASIC, "realm"); + challenge.getParameters().add(new Parameter("charset", "UTF-8")); + response.getChallengeRequests().add(challenge); + + ChallengeResponse cr = + new ChallengeResponse(challenge, response, "josé", "pässwörd".toCharArray()); + + assertEquals( + "Basic " + base64Of("josé", "pässwörd", StandardCharsets.ISO_8859_1), + AuthenticatorUtils.formatResponse(cr, response.getRequest(), null)); + } + + // ---- Server side: advertising the charset in the challenge ------------------------------- + + @Test + void formatRequest_charsetParameter_advertisesUtf8() { + ChallengeRequest challenge = new ChallengeRequest(ChallengeScheme.HTTP_BASIC, "realm"); + challenge.getParameters().add(new Parameter("charset", "UTF-8")); + + assertEquals( + "Basic realm=\"realm\", charset=\"UTF-8\"", + AuthenticatorUtils.formatRequest(challenge, new Response(null), null)); + } + + @Test + void formatRequest_noCharsetParameter_realmOnly() { + ChallengeRequest challenge = new ChallengeRequest(ChallengeScheme.HTTP_BASIC, "realm"); + + assertEquals( + "Basic realm=\"realm\"", + AuthenticatorUtils.formatRequest(challenge, new Response(null), null)); + } + + // ---- Client side: encoding the Authorization header -------------------------------------- + + @Test + void formatResponse_default_encodesIso88591() { + ChallengeResponse cr = + new ChallengeResponse(ChallengeScheme.HTTP_BASIC, LATIN1_LOGIN, LATIN1_PASSWORD); + + assertEquals( + "Basic " + base64Of(LATIN1_LOGIN, LATIN1_PASSWORD, StandardCharsets.ISO_8859_1), + AuthenticatorUtils.formatResponse( + cr, new Request(Method.GET, "http://localhost/"), null)); + } + + @Test + void formatResponse_charsetParameter_encodesUtf8() { + ChallengeResponse cr = + new ChallengeResponse(ChallengeScheme.HTTP_BASIC, UTF8_LOGIN, UTF8_PASSWORD); + cr.getParameters().add(new Parameter("charset", "UTF-8")); + + assertEquals( + "Basic " + base64Of(UTF8_LOGIN, UTF8_PASSWORD, StandardCharsets.UTF_8), + AuthenticatorUtils.formatResponse( + cr, new Request(Method.GET, "http://localhost/"), null)); + } + + /** + * Issue #1455: a client that answers a challenge advertising {@code charset="UTF-8"} should + * encode its credentials in UTF-8 without any manual parameter. + */ + @Test + void formatResponse_challengeAdvertisedUtf8_encodesUtf8() { + Response response = new Response(new Request(Method.GET, "http://localhost/")); + ChallengeRequest challenge = new ChallengeRequest(ChallengeScheme.HTTP_BASIC, "realm"); + challenge.getParameters().add(new Parameter("charset", "UTF-8")); + response.getChallengeRequests().add(challenge); + + ChallengeResponse cr = + new ChallengeResponse(challenge, response, UTF8_LOGIN, UTF8_PASSWORD.toCharArray()); + + assertEquals( + "Basic " + base64Of(UTF8_LOGIN, UTF8_PASSWORD, StandardCharsets.UTF_8), + AuthenticatorUtils.formatResponse(cr, response.getRequest(), null)); + } +} diff --git a/org.restlet/src/test/java/org/restlet/security/ChallengeAuthenticatorTestCase.java b/org.restlet/src/test/java/org/restlet/security/ChallengeAuthenticatorTestCase.java index f872d35c9a..a0a7f43396 100644 --- a/org.restlet/src/test/java/org/restlet/security/ChallengeAuthenticatorTestCase.java +++ b/org.restlet/src/test/java/org/restlet/security/ChallengeAuthenticatorTestCase.java @@ -21,6 +21,7 @@ import org.restlet.data.ChallengeRequest; import org.restlet.data.ChallengeScheme; import org.restlet.data.Status; +import org.restlet.engine.security.HttpBasicHelper; /** Unit tests for {@link ChallengeAuthenticator}. */ class ChallengeAuthenticatorTestCase { @@ -305,4 +306,43 @@ void isRechallengingSetRechallenging_roundTrip() { authenticator.setRechallenging(false); assertFalse(authenticator.isRechallenging()); } + + /** Issue #1455: a Basic challenge advertises UTF-8 credentials (RFC 7617 section 2.1). */ + @Test + void createChallengeRequest_basic_advertisesUtf8() { + System.setProperty(HttpBasicHelper.UTF8_PROPERTY, "true"); + try { + assertAdvertisesUtf8(); + } finally { + System.clearProperty(HttpBasicHelper.UTF8_PROPERTY); + } + } + + /** The default is unchanged: without the opt-in, no charset is advertised. */ + @Test + void createChallengeRequest_basic_utf8Disabled_hasNoCharsetParameter() { + ChallengeAuthenticator authenticator = + new ChallengeAuthenticator(null, ChallengeScheme.HTTP_BASIC, "realm"); + assertNull( + authenticator + .createChallengeRequest(false) + .getParameters() + .getFirstValue("charset")); + } + + private void assertAdvertisesUtf8() { + ChallengeAuthenticator authenticator = + new ChallengeAuthenticator(null, ChallengeScheme.HTTP_BASIC, "realm"); + ChallengeRequest challenge = authenticator.createChallengeRequest(false); + assertEquals("realm", challenge.getRealm()); + assertEquals("UTF-8", challenge.getParameters().getFirstValue("charset")); + } + + @Test + void createChallengeRequest_digest_hasNoCharsetParameter() { + ChallengeAuthenticator authenticator = + new ChallengeAuthenticator(null, ChallengeScheme.HTTP_DIGEST, "realm"); + ChallengeRequest challenge = authenticator.createChallengeRequest(false); + assertNull(challenge.getParameters().getFirstValue("charset")); + } } diff --git a/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java b/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java index 0cc9455287..3289c53644 100644 --- a/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java +++ b/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java @@ -34,6 +34,7 @@ import org.restlet.data.Protocol; import org.restlet.data.Status; import org.restlet.engine.Engine; +import org.restlet.engine.security.HttpBasicHelper; /** * Restlet unit tests for HTTP Basic authentication client/server. @@ -49,6 +50,13 @@ class HttpBasicTestCase { public static final String SHORT_PASSWORD = "pw15"; public static final String SHORT_USERNAME = "user13"; public static final String WRONG_USERNAME = "wrongUser"; + public static final String LATIN1_USERNAME = "josé"; + public static final String LATIN1_PASSWORD = "pässwörd"; + + /** The euro sign does not exist in ISO-8859-1. */ + public static final String UTF8_USERNAME = "zoë"; + + public static final String UTF8_PASSWORD = "p€ss"; static Stream invalidCredentials() { return Stream.of( @@ -66,6 +74,8 @@ public static class TestVerifier extends MapVerifier { public TestVerifier() { getLocalSecrets().put(SHORT_USERNAME, SHORT_PASSWORD.toCharArray()); getLocalSecrets().put(LONG_USERNAME, LONG_PASSWORD.toCharArray()); + getLocalSecrets().put(LATIN1_USERNAME, LATIN1_PASSWORD.toCharArray()); + getLocalSecrets().put(UTF8_USERNAME, UTF8_PASSWORD.toCharArray()); } @Override @@ -135,6 +145,44 @@ void testValidCredentials(final String login, final String password) throws Exce assertEquals(AUTHENTICATED_MSG, response.getEntity().getText()); } + /** Clients that predate RFC 7617 and send ISO-8859-1 must keep working. */ + @Test + void testLatin1CredentialsWithoutCharset() throws Exception { + request.setChallengeResponse( + new ChallengeResponse( + ChallengeScheme.HTTP_BASIC, LATIN1_USERNAME, LATIN1_PASSWORD)); + + final Response response = client.handle(request); + assertEquals(Status.SUCCESS_OK, response.getStatus()); + } + + /** Issue #1455: credentials sent as UTF-8 are accepted by the server. */ + @Test + void testUtf8CredentialsWithCharset() throws Exception { + System.setProperty(HttpBasicHelper.UTF8_PROPERTY, "true"); + ChallengeResponse authentication = + new ChallengeResponse(ChallengeScheme.HTTP_BASIC, UTF8_USERNAME, UTF8_PASSWORD); + authentication.getParameters().add("charset", "UTF-8"); + request.setChallengeResponse(authentication); + + final Response response = client.handle(request); + assertEquals(Status.SUCCESS_OK, response.getStatus()); + } + + /** Issue #1455: the server challenge carries charset="UTF-8". */ + @Test + void testChallengeAdvertisesUtf8() { + System.setProperty(HttpBasicHelper.UTF8_PROPERTY, "true"); + final Response response = client.handle(request); + assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus()); + assertEquals( + "UTF-8", + response.getChallengeRequests() + .get(0) + .getParameters() + .getFirstValue("charset")); + } + @BeforeEach void makeServer() throws Exception { Engine.clear(); @@ -165,6 +213,7 @@ public Restlet createInboundRoot() { @AfterEach void cleanup() throws Exception { + System.clearProperty(HttpBasicHelper.UTF8_PROPERTY); client.stop(); if (this.component.isStarted()) { this.component.stop(); From 3187e4b00eff62f69fb6fdde6e09f8e23537bffc Mon Sep 17 00:00:00 2001 From: Thierry Boileau Date: Sat, 31 Oct 2026 21:48:18 +0100 Subject: [PATCH 2/4] HTTP Basic: match the realm and charset parameters case-insensitively RFC 9110 section 11.2 makes auth-param names case-insensitive. "Realm=" was ignored (null realm) and "Charset=" was not seen by the client. Co-Authored-By: Claude Sonnet 5.5 --- changes.md | 1 + .../engine/security/AuthenticatorUtils.java | 4 ++-- .../engine/security/HttpBasicHelper.java | 6 ++--- .../security/HttpBasicHelperTestCase.java | 22 +++++++++++++++++++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/changes.md b/changes.md index 0fe8fffb65..a50022e46e 100644 --- a/changes.md +++ b/changes.md @@ -17,6 +17,7 @@ Changes log - CookieAuthenticator no longer lets the next restlet overwrite the redirection sent after a login. Issue #554. - CorsService and CorsFilter now add `Vary: Origin` to every response when the CORS answer depends on the request origin (origin allow-list or credentials allowed), including requests without an `Origin` header, so shared caches no longer serve the response of one origin to another. A static `*` without credentials still sends no `Vary`, as recommended by the Fetch standard. Issue #1611. - Fixed the parsing of a `WWW-Authenticate` challenge made of a token68 ending with `=` (such as a `Negotiate` token): the whole header was dropped. Issue #191. + - The `realm` and `charset` parameters of an HTTP Basic challenge are now recognized whatever their case (`Realm=`, `Charset=`), as required by RFC 9110. Issue #1455. - 2.7.0 (30-09-2026) - Misc diff --git a/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java b/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java index 5e2f055103..c059339ee5 100644 --- a/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java +++ b/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java @@ -366,11 +366,11 @@ public static void update( challengeResponse.setOpaque(challengeRequest.getOpaque()); // Credentials charset advertised by the server (RFC 7617, section 2.1) - String charset = challengeRequest.getParameters().getFirstValue("charset"); + String charset = challengeRequest.getParameters().getFirstValue("charset", true); if (HttpBasicHelper.isUtf8Enabled() && ChallengeScheme.HTTP_BASIC.equals(challengeResponse.getScheme()) && charset != null - && challengeResponse.getParameters().getFirstValue("charset") == null) { + && challengeResponse.getParameters().getFirstValue("charset", true) == null) { challengeResponse.getParameters().add("charset", charset); } } diff --git a/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java b/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java index 71df60fbbf..292657f771 100644 --- a/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java +++ b/org.restlet/src/main/java/org/restlet/engine/security/HttpBasicHelper.java @@ -74,7 +74,7 @@ public void formatRequest( Response response, Series

httpHeaders) { String realm = challenge.getRealm(); - String charset = challenge.getParameters().getFirstValue(CHARSET); + String charset = challenge.getParameters().getFirstValue(CHARSET, true); if (realm != null) { cw.appendQuotedChallengeParameter(REALM, realm); @@ -146,7 +146,7 @@ public void parseRequest( while (param != null) { try { - if (REALM.equals(param.getName())) { + if (REALM.equalsIgnoreCase(param.getName())) { challenge.setRealm(param.getValue()); } else { challenge.getParameters().add(param); @@ -232,7 +232,7 @@ private static String decode(byte[] bytes) { * @return The charset. */ private String getCharset(final ChallengeResponse challenge) { - String charset = challenge.getParameters().getFirstValue(CHARSET); + String charset = challenge.getParameters().getFirstValue(CHARSET, true); if (charset == null) { charset = ISO_8859_1; diff --git a/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java b/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java index 718c07fce4..879433f243 100644 --- a/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java +++ b/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java @@ -184,4 +184,26 @@ void formatResponse_challengeAdvertisedUtf8_encodesUtf8() { "Basic " + base64Of(UTF8_LOGIN, UTF8_PASSWORD, StandardCharsets.UTF_8), AuthenticatorUtils.formatResponse(cr, response.getRequest(), null)); } + + // ---- Case insensitivity of the parameter names (RFC 9110 section 11.2) + // ----------------------- + + @Test + void parseRequest_parameterNamesInAnyCase_realmAndCharsetRecognized() { + Response response = new Response(new Request(Method.GET, "http://localhost/")); + ChallengeRequest challenge = + AuthenticatorUtils.parseRequest( + response, "Basic REALM=\"r\", Charset=\"utf-8\"", null) + .get(0); + + assertEquals("r", challenge.getRealm()); + assertEquals("utf-8", challenge.getParameters().getFirstValue("charset", true)); + + response.getChallengeRequests().add(challenge); + ChallengeResponse cr = + new ChallengeResponse(challenge, response, UTF8_LOGIN, UTF8_PASSWORD.toCharArray()); + assertEquals( + "Basic " + base64Of(UTF8_LOGIN, UTF8_PASSWORD, StandardCharsets.UTF_8), + AuthenticatorUtils.formatResponse(cr, response.getRequest(), null)); + } } From f845f000aa437cdbf02055bf571964b59530b5eb Mon Sep 17 00:00:00 2001 From: Thierry Boileau Date: Sat, 31 Oct 2026 21:52:20 +0100 Subject: [PATCH 3/4] HTTP Basic tests: register their own engine Another test of the suite can leave a Restlet engine without the Basic authenticator helper (seen on CI, depending on the test order): the new tests now register a complete engine first. Co-Authored-By: Claude Sonnet 5.5 --- .../org/restlet/engine/security/AuthenticatorUtilsTestCase.java | 2 ++ .../org/restlet/engine/security/HttpBasicHelperTestCase.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsTestCase.java b/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsTestCase.java index 1d0af63513..7c3e6b8545 100644 --- a/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsTestCase.java +++ b/org.restlet/src/test/java/org/restlet/engine/security/AuthenticatorUtilsTestCase.java @@ -22,6 +22,7 @@ import org.restlet.data.ChallengeResponse; import org.restlet.data.ChallengeScheme; import org.restlet.data.Method; +import org.restlet.engine.Engine; class AuthenticatorUtilsTestCase { @@ -198,6 +199,7 @@ void updateReference_rawValuePresent_returnsUnchangedReference() { @Test void parseRequest_twoChallenges_eachGetsItsSchemeAndRealm() { + Engine.register(); // other tests may leave an engine without the Basic helper List result = AuthenticatorUtils.parseRequest( new Response(new Request(Method.GET, "http://localhost/")), diff --git a/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java b/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java index 879433f243..7a47864db5 100644 --- a/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java +++ b/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java @@ -22,6 +22,7 @@ import org.restlet.data.ChallengeScheme; import org.restlet.data.Method; import org.restlet.data.Parameter; +import org.restlet.engine.Engine; /** * Unit tests for the credentials charset handling of {@link HttpBasicHelper} (RFC 7617 section @@ -31,6 +32,7 @@ class HttpBasicHelperTestCase { @BeforeEach void enableUtf8() { + Engine.register(); // other tests may leave an engine without the Basic helper System.setProperty(HttpBasicHelper.UTF8_PROPERTY, "true"); } From c60a87f4509182e2de620ad206cf99cfa448180b Mon Sep 17 00:00:00 2001 From: Thierry Boileau Date: Sun, 1 Nov 2026 12:31:04 +0100 Subject: [PATCH 4/4] Fix Sonar issues --- .../restlet/engine/security/AuthenticatorUtils.java | 8 +++++--- .../engine/header/ChallengeRequestReaderTestCase.java | 10 +++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java b/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java index c059339ee5..e8c6c28b31 100644 --- a/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java +++ b/org.restlet/src/main/java/org/restlet/engine/security/AuthenticatorUtils.java @@ -37,6 +37,8 @@ */ public class AuthenticatorUtils { + private static final String CHARSET_PARAMETER = "charset"; + /** * Indicates if any of the objects is null. * @@ -366,12 +368,12 @@ public static void update( challengeResponse.setOpaque(challengeRequest.getOpaque()); // Credentials charset advertised by the server (RFC 7617, section 2.1) - String charset = challengeRequest.getParameters().getFirstValue("charset", true); + String charset = challengeRequest.getParameters().getFirstValue(CHARSET_PARAMETER, true); if (HttpBasicHelper.isUtf8Enabled() && ChallengeScheme.HTTP_BASIC.equals(challengeResponse.getScheme()) && charset != null - && challengeResponse.getParameters().getFirstValue("charset", true) == null) { - challengeResponse.getParameters().add("charset", charset); + && challengeResponse.getParameters().getFirstValue(CHARSET_PARAMETER, true) == null) { + challengeResponse.getParameters().add(CHARSET_PARAMETER, charset); } } diff --git a/org.restlet/src/test/java/org/restlet/engine/header/ChallengeRequestReaderTestCase.java b/org.restlet/src/test/java/org/restlet/engine/header/ChallengeRequestReaderTestCase.java index 03beefe3ae..db773ec624 100644 --- a/org.restlet/src/test/java/org/restlet/engine/header/ChallengeRequestReaderTestCase.java +++ b/org.restlet/src/test/java/org/restlet/engine/header/ChallengeRequestReaderTestCase.java @@ -41,7 +41,7 @@ void readValue_emptyHeader_returnsNull() throws IOException { } @Test - void readValues_twoChallenges_returnsBoth() throws IOException { + void readValues_twoChallenges_returnsBoth() { List result = new ChallengeRequestReader("Basic realm=\"a\", Digest realm=\"b\", nonce=\"x\"") .readValues(); @@ -53,7 +53,7 @@ void readValues_twoChallenges_returnsBoth() throws IOException { } @Test - void readValues_schemeWithoutParametersThenChallenge_returnsBoth() throws IOException { + void readValues_schemeWithoutParametersThenChallenge_returnsBoth() { List result = new ChallengeRequestReader("Negotiate, Basic realm=\"a\"").readValues(); assertEquals(2, result.size()); @@ -62,16 +62,16 @@ void readValues_schemeWithoutParametersThenChallenge_returnsBoth() throws IOExce } @Test - void readValues_commaInsideQuotedValue_staysInOneChallenge() throws IOException { + void readValues_commaInsideQuotedValue_staysInOneChallenge() { List result = new ChallengeRequestReader("Basic realm=\"a, Digest b\"").readValues(); assertEquals(1, result.size()); - assertEquals("Basic", result.get(0).getScheme().getTechnicalName()); + assertEquals("Basic", result.getFirst().getScheme().getTechnicalName()); } /** RFC 9110 section 11.2: token68 may end with "=" padding, as Negotiate tokens do. */ @Test - void readValues_token68Challenge_keepsToken() throws IOException { + void readValues_token68Challenge_keepsToken() { List result = new ChallengeRequestReader("Negotiate YIIabc==, Basic realm=\"a\"").readValues(); assertEquals(2, result.size());