diff --git a/changes.md b/changes.md index 1125222843..a50022e46e 100644 --- a/changes.md +++ b/changes.md @@ -9,12 +9,15 @@ 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. + - 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/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..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. * @@ -364,6 +366,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_PARAMETER, true); + if (HttpBasicHelper.isUtf8Enabled() + && ChallengeScheme.HTTP_BASIC.equals(challengeResponse.getScheme()) + && charset != null + && challengeResponse.getParameters().getFirstValue(CHARSET_PARAMETER, true) == null) { + challengeResponse.getParameters().add(CHARSET_PARAMETER, 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..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 @@ -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); @@ -50,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); @@ -122,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); @@ -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. @@ -185,7 +232,7 @@ public void parseResponse( * @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/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..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 @@ -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() { + 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() { + 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() { + List result = + new ChallengeRequestReader("Basic realm=\"a, Digest b\"").readValues(); + assertEquals(1, result.size()); + 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() { + 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..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 @@ -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; @@ -21,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 { @@ -194,4 +196,20 @@ void updateReference_rawValuePresent_returnsUnchangedReference() { AuthenticatorUtils.updateReference( request.getResourceRef(), challengeResponse, request)); } + + @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/")), + "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..7a47864db5 --- /dev/null +++ b/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java @@ -0,0 +1,211 @@ +/** + * 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; +import org.restlet.engine.Engine; + +/** + * Unit tests for the credentials charset handling of {@link HttpBasicHelper} (RFC 7617 section + * 2.1), without any network. + */ +class HttpBasicHelperTestCase { + + @BeforeEach + void enableUtf8() { + Engine.register(); // other tests may leave an engine without the Basic helper + 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)); + } + + // ---- 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)); + } +} 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();