diff --git a/changes.md b/changes.md index d3eb61f75a..9ed04a1785 100644 --- a/changes.md +++ b/changes.md @@ -77,6 +77,14 @@ Changes log explicitly. Without a valid configuration, `CorsFilter` adds no CORS headers. Issue #1611. - CORS: `CorsFilter` now applies configuration changes made after the first request (the internal `CorsResponseHelper` is rebuilt by the setters). Issue #1611. + - HTTP Basic: the credentials sent by a client are now decoded as UTF-8, falling back to + ISO-8859-1 when the bytes are not valid UTF-8. A `ChallengeAuthenticator` using + `ChallengeScheme.HTTP_BASIC` now advertises `charset="UTF-8"` in its challenge (RFC 7617), + and a `ChallengeResponse` built from a challenge request copies that `charset` + parameter, so the client encodes its credentials in UTF-8. The charsets can be changed per + authenticator with `ChallengeAuthenticator.setCredentialsCharset()` (UTF-8 by default, + advertised in the challenge only when it is UTF-8) and `setCredentialsFallbackCharset()` + (ISO-8859-1 by default, null to reject invalid UTF-8). Issue #1455. - STDIO connector construction fails fast without an explicit or uniquely registered supporting helper class. Applications with multiple STDIO protocol helpers must select one explicitly. @@ -160,6 +168,10 @@ 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. - Misc - Fixed JDK HTTP client accessibility in core and WebSocket tests in Eclipse and VS Code, without adding a production module dependency. 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 61b0ab67de..8593a79e85 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. * @@ -362,6 +364,14 @@ 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 (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..42915994fa 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,11 @@ 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.Charset; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.logging.Level; import org.restlet.Context; @@ -50,7 +55,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 +127,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,31 +157,66 @@ public void parseResponse( } try { - String charset = getCharset(challenge); - - byte[] credentialsEncoded = Base64.getDecoder().decode(challenge.getRawValue()); - - String credentials = new String(credentialsEncoded, charset); - int separator = credentials.indexOf(':'); - - if (separator == -1) { + if (!parseCredentials(challenge, StandardCharsets.UTF_8, StandardCharsets.ISO_8859_1)) { // Log the blocking getLogger() .log( Level.INFO, "Invalid credentials given by client with IP: {0}", request != null ? request.getClientInfo().getAddress() : "?"); - } else { - challenge.setIdentifier(credentials.substring(0, separator)); - challenge.setSecret(credentials.substring(separator + 1)); } - } catch (UnsupportedEncodingException e) { - getLogger().log(Level.INFO, "Unsupported HTTP Basic encoding error", e); } catch (IllegalArgumentException e) { getLogger().log(Level.INFO, "Unable to decode the HTTP Basic credential", e); } } + /** + * Decodes the Base64 credentials of a challenge response and sets its identifier and secret. + * 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 decoded with the primary charset and, if + * they are not valid in it, with the fallback charset. + * + * @param challenge The challenge response, with its raw value set. + * @param primary The charset tried first. + * @param fallback The charset used if the bytes are not valid in the primary one, or null for + * none. + * @return True if the identifier and secret were set, false if the decoded credentials have no + * ":" separator. + * @throws IllegalArgumentException If the raw value is not valid Base64 or the bytes are not + * valid in the primary charset without a fallback. + */ + public static boolean parseCredentials( + ChallengeResponse challenge, Charset primary, Charset fallback) { + byte[] bytes = Base64.getDecoder().decode(challenge.getRawValue()); + String credentials; + + try { + credentials = + primary.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + } catch (CharacterCodingException e) { + if (fallback == null) { + throw new IllegalArgumentException( + "The credentials are not valid in " + primary.name(), e); + } + + credentials = new String(bytes, fallback); + } + + int separator = credentials.indexOf(':'); + + if (separator == -1) { + return false; + } + + challenge.setIdentifier(credentials.substring(0, separator)); + challenge.setSecret(credentials.substring(separator + 1)); + return true; + } + /** * 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 +225,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..fc9cdb41e9 100644 --- a/org.restlet/src/main/java/org/restlet/security/ChallengeAuthenticator.java +++ b/org.restlet/src/main/java/org/restlet/security/ChallengeAuthenticator.java @@ -8,6 +8,9 @@ */ package org.restlet.security; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Objects; import java.util.logging.Level; import org.restlet.Context; import org.restlet.Request; @@ -17,6 +20,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 @@ -29,6 +33,18 @@ */ public class ChallengeAuthenticator extends Authenticator { + /** + * The charset of the HTTP Basic credentials, advertised in the challenge when it is UTF-8 (RFC + * 7617). + */ + private volatile Charset credentialsCharset = StandardCharsets.UTF_8; + + /** + * The charset used when the HTTP Basic credentials are not valid in the credentials charset, or + * null for none. + */ + private volatile Charset credentialsFallbackCharset = StandardCharsets.ISO_8859_1; + /** The authentication realm. */ private volatile String realm; @@ -120,6 +136,8 @@ protected boolean authenticate(Request request, Response response) { boolean loggable = request.isLoggable() && getLogger().isLoggable(Level.FINE); if (getVerifier() != null) { + decodeBasicCredentials(request); + switch (getVerifier().verify(request, response)) { case Verifier.RESULT_VALID: // Valid credentials provided @@ -222,7 +240,15 @@ 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()) + && StandardCharsets.UTF_8.equals(getCredentialsCharset())) { + // RFC 7617 section 2.1: the only charset that can be advertised is UTF-8 + result.getParameters().add("charset", "UTF-8"); + } + + return result; } /** @@ -247,6 +273,51 @@ public void forbid(Response response) { response.setStatus(Status.CLIENT_ERROR_FORBIDDEN); } + /** + * Decodes again the HTTP Basic credentials with the charsets of this authenticator, when they + * differ from the default decoding (UTF-8, then ISO-8859-1). + * + * @param request The request holding the challenge response. + */ + private void decodeBasicCredentials(Request request) { + ChallengeResponse cr = request.getChallengeResponse(); + + if (cr != null + && ChallengeScheme.HTTP_BASIC.equals(cr.getScheme()) + && cr.getRawValue() != null + && (!StandardCharsets.UTF_8.equals(credentialsCharset) + || !StandardCharsets.ISO_8859_1.equals(credentialsFallbackCharset))) { + try { + HttpBasicHelper.parseCredentials( + cr, credentialsCharset, credentialsFallbackCharset); + } catch (IllegalArgumentException e) { + getLogger().log(Level.INFO, "Unable to decode the HTTP Basic credential", e); + cr.setIdentifier(null); + cr.setSecret((char[]) null); + } + } + } + + /** + * Returns the charset of the HTTP Basic credentials. Defaults to UTF-8. + * + * @return The credentials charset. + * @see #setCredentialsCharset(Charset) + */ + public Charset getCredentialsCharset() { + return credentialsCharset; + } + + /** + * Returns the charset used when the HTTP Basic credentials are not valid in the credentials + * charset. Defaults to ISO-8859-1. + * + * @return The fallback charset, or null if there is none. + */ + public Charset getCredentialsFallbackCharset() { + return credentialsFallbackCharset; + } + /** * Returns the authentication realm. * @@ -285,6 +356,28 @@ public boolean isRechallenging() { return this.rechallenging; } + /** + * Sets the charset of the HTTP Basic credentials (RFC 7617 section 2.1). The credentials are + * read with it, and the challenge advertises {@code charset="UTF-8"} when it is UTF-8, the only + * value allowed by the RFC. Defaults to UTF-8. + * + * @param credentialsCharset The credentials charset. + */ + public void setCredentialsCharset(Charset credentialsCharset) { + this.credentialsCharset = Objects.requireNonNull(credentialsCharset); + } + + /** + * Sets the charset used when the HTTP Basic credentials are not valid in the credentials + * charset, as clients that predate RFC 7617 send ISO-8859-1. Defaults to ISO-8859-1. Use null + * to reject such credentials. + * + * @param credentialsFallbackCharset The fallback charset, or null for none. + */ + public void setCredentialsFallbackCharset(Charset credentialsFallbackCharset) { + this.credentialsFallbackCharset = credentialsFallbackCharset; + } + /** * Sets the authentication realm. * 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..7af9d95576 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.get(0).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..79c7439400 --- /dev/null +++ b/org.restlet/src/test/java/org/restlet/engine/security/HttpBasicHelperTestCase.java @@ -0,0 +1,213 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +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 registerEngine() { + Engine.register(); // other tests may leave an engine without the Basic helper + } + + 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, Charset cs) { + byte[] bytes = (login + ":" + password).getBytes(cs); + return "Basic " + Base64.getEncoder().encodeToString(bytes); + } + + private static String base64Of(String login, String password, 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())); + } + + // ---- 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)); + } + + // ---- parseCredentials -------------------------------------------------------------------- + + private static ChallengeResponse rawResponse(String login, String password, Charset cs) { + ChallengeResponse cr = new ChallengeResponse(ChallengeScheme.HTTP_BASIC); + cr.setRawValue(base64Of(login, password, cs)); + return cr; + } + + @Test + void parseCredentials_invalidInPrimaryWithoutFallback_throws() { + ChallengeResponse cr = + rawResponse(LATIN1_LOGIN, LATIN1_PASSWORD, StandardCharsets.ISO_8859_1); + assertThrows( + IllegalArgumentException.class, + () -> HttpBasicHelper.parseCredentials(cr, StandardCharsets.UTF_8, null)); + } + + @Test + void parseCredentials_iso88591Primary_readsUtf8BytesAsLatin1() { + ChallengeResponse cr = rawResponse("zoë", "pw", StandardCharsets.UTF_8); + assertTrue(HttpBasicHelper.parseCredentials(cr, StandardCharsets.ISO_8859_1, null)); + assertEquals("zoë", cr.getIdentifier()); + } + + @Test + void parseCredentials_noSeparator_returnsFalse() { + ChallengeResponse cr = new ChallengeResponse(ChallengeScheme.HTTP_BASIC); + cr.setRawValue(Base64.getEncoder().encodeToString("nocolon".getBytes())); + assertFalse( + HttpBasicHelper.parseCredentials( + cr, StandardCharsets.UTF_8, StandardCharsets.ISO_8859_1)); + } +} 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..d49b8d88e1 100644 --- a/org.restlet/src/test/java/org/restlet/security/ChallengeAuthenticatorTestCase.java +++ b/org.restlet/src/test/java/org/restlet/security/ChallengeAuthenticatorTestCase.java @@ -14,13 +14,19 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import org.junit.jupiter.api.Test; import org.restlet.Context; import org.restlet.Request; import org.restlet.Response; import org.restlet.data.ChallengeRequest; import org.restlet.data.ChallengeScheme; +import org.restlet.data.Method; import org.restlet.data.Status; +import org.restlet.engine.Engine; +import org.restlet.engine.security.AuthenticatorUtils; /** Unit tests for {@link ChallengeAuthenticator}. */ class ChallengeAuthenticatorTestCase { @@ -305,4 +311,87 @@ 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() { + 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")); + } + + // ---- HTTP Basic credentials charset (RFC 7617 section 2.1) + // ----------------------------------- + + private static boolean authenticate( + ChallengeAuthenticator authenticator, String id, String secret, Charset charset) { + Engine.register(); // other tests may leave an engine without the Basic helper + Request request = new Request(Method.GET, "http://localhost/"); + String header = + "Basic " + + Base64.getEncoder().encodeToString((id + ":" + secret).getBytes(charset)); + request.setChallengeResponse(AuthenticatorUtils.parseResponse(request, header, null)); + return authenticator.authenticate(request, new Response(request)); + } + + private static ChallengeAuthenticator basicAuthenticator(String id, String secret) { + MapVerifier verifier = new MapVerifier(); + verifier.getLocalSecrets().put(id, secret.toCharArray()); + ChallengeAuthenticator authenticator = + new ChallengeAuthenticator(null, ChallengeScheme.HTTP_BASIC, "realm"); + authenticator.setVerifier(verifier); + return authenticator; + } + + @Test + void credentialsCharset_defaults_utf8WithIso88591Fallback() { + ChallengeAuthenticator authenticator = basicAuthenticator("zoë", "p€ss"); + assertEquals(StandardCharsets.UTF_8, authenticator.getCredentialsCharset()); + assertEquals(StandardCharsets.ISO_8859_1, authenticator.getCredentialsFallbackCharset()); + assertTrue(authenticate(authenticator, "zoë", "p€ss", StandardCharsets.UTF_8)); + } + + @Test + void credentialsCharset_defaults_latin1ClientStillAccepted() { + ChallengeAuthenticator authenticator = basicAuthenticator("josé", "pässwörd"); + assertTrue(authenticate(authenticator, "josé", "pässwörd", StandardCharsets.ISO_8859_1)); + } + + /** A Latin-1 password whose bytes are valid UTF-8 ("é") is read as UTF-8 by default. */ + @Test + void credentialsCharset_defaults_latin1PasswordThatIsValidUtf8_collides() { + ChallengeAuthenticator authenticator = basicAuthenticator("user", "é"); + assertFalse(authenticate(authenticator, "user", "é", StandardCharsets.ISO_8859_1)); + } + + /** The collision above is solved by configuring ISO-8859-1 on the authenticator. */ + @Test + void credentialsCharset_iso88591_latin1PasswordThatIsValidUtf8_accepted() { + ChallengeAuthenticator authenticator = basicAuthenticator("user", "é"); + authenticator.setCredentialsCharset(StandardCharsets.ISO_8859_1); + assertTrue(authenticate(authenticator, "user", "é", StandardCharsets.ISO_8859_1)); + assertNull( + authenticator + .createChallengeRequest(false) + .getParameters() + .getFirstValue("charset")); + } + + @Test + void credentialsFallbackCharset_null_rejectsLatin1Credentials() { + ChallengeAuthenticator authenticator = basicAuthenticator("josé", "pässwörd"); + authenticator.setCredentialsFallbackCharset(null); + assertFalse(authenticate(authenticator, "josé", "pässwörd", StandardCharsets.ISO_8859_1)); + assertTrue(authenticate(authenticator, "josé", "pässwörd", StandardCharsets.UTF_8)); + } } 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..fe8516bc40 100644 --- a/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java +++ b/org.restlet/src/test/java/org/restlet/security/HttpBasicTestCase.java @@ -49,6 +49,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 +73,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 +144,42 @@ 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() { + 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() { + 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() { + final Response response = client.handle(request); + assertEquals(Status.CLIENT_ERROR_UNAUTHORIZED, response.getStatus()); + assertEquals( + "UTF-8", + response.getChallengeRequests() + .getFirst() + .getParameters() + .getFirstValue("charset")); + } + @BeforeEach void makeServer() throws Exception { Engine.clear();