Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>)`) 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ public HeaderWriter<Parameter> 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();
Expand Down Expand Up @@ -82,4 +89,48 @@ public HeaderWriter<Parameter> 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 == '/';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
*/
public class AuthenticatorUtils {

private static final String CHARSET_PARAMETER = "charset";

/**
* Indicates if any of the objects is null.
*
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -50,7 +74,7 @@ public void formatRequest(
Response response,
Series<Header> 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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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.
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -38,4 +39,44 @@ void readValue_emptyHeader_returnsNull() throws IOException {
ChallengeRequestReader reader = new ChallengeRequestReader("");
assertNull(reader.readValue());
}

@Test
void readValues_twoChallenges_returnsBoth() {
List<ChallengeRequest> 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<ChallengeRequest> 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<ChallengeRequest> 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<ChallengeRequest> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {

Expand Down Expand Up @@ -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<ChallengeRequest> 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());
}
}
Loading
Loading