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
9 changes: 9 additions & 0 deletions changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ Changes log
`$Domain`. When reading, these legacy pairs and attributes are silently ignored, and a
comma is no longer a separator between cookies (several `Cookie` headers are joined with
semicolons). `Max-Age` now takes precedence over `Expires` when both are present.
- CORS: allowing credentials together with the `*` origin (`CorsService`, `CorsFilter`) is now
rejected with an `IllegalStateException` (when the service creates its filter, or when the
filter starts), because it required echoing any request origin. List the allowed origins
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.
- Enhancements
- Added the `EventStreamRepresentation`, `ServerSentEvent` and `EventStreamReader` APIs for
streaming and reading Server-Sent Events over the HTTP connectors, including optional
Expand Down Expand Up @@ -103,15 +109,18 @@ Changes log
- HTTP/2 and HTTP/3 requests advertise `te: trailers`; stream header names are normalized
to lowercase at the HTTP boundary.
- Added the `Protocol.STDIO` pseudo-protocol constant for standard input/output connectors.
- 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. Issue #1611.
- Added the `SameSite` attribute to cookie settings (`CookieSetting.get/setSameSite()`,
`SameSite` enum), read and written in `Set-Cookie`.
- Bugs fixed
- Fixed the OpenAPI extension build path for Commons Lang types inherited from Swagger.
- 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 cookie setting are no longer quoted. Issue #1334.
- `Restlet.handle()` no longer fails with a `NullPointerException` when the Restlet has no context and cannot be started.
- `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.
- Misc
- Fixed JDK HTTP client accessibility in core and WebSocket tests in Eclipse and VS Code,
without adding a production module dependency.
Expand Down
8 changes: 6 additions & 2 deletions org.restlet/src/main/java/org/restlet/Restlet.java
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,12 @@ public void handle(Request request, Response response) {

if (!isStarted()) {
// No exception raised but the Restlet somehow couldn't be
// started
getContext().getLogger().log(Level.WARNING, UNABLE_TO_START);
// started (or it failed above)
if (getContext() != null) {
getContext().getLogger().log(Level.WARNING, UNABLE_TO_START);
} else {
Context.getCurrentLogger().log(Level.WARNING, UNABLE_TO_START);
}
response.setStatus(Status.SERVER_ERROR_INTERNAL);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;
import java.util.logging.Level;
import org.restlet.Context;
import org.restlet.Request;
import org.restlet.Response;
Expand All @@ -23,6 +25,12 @@
* Filter that helps support CORS requests. This filter lets the target resources specify the
* allowed methods.
*
* <p><b>Credentials:</b> the Fetch standard does not accept the wildcard {@code *} in {@code
* Access-Control-Allow-Origin} for requests with credentials. Allowing credentials together with
* the {@code *} origin would therefore require echoing any request origin, so this configuration is
* rejected with an {@link IllegalStateException}: list the allowed origins explicitly (or use an
* origin matcher) when credentials are allowed.
*
* <p>Example:
*
* <pre>
Expand Down Expand Up @@ -56,8 +64,13 @@ public class CorsFilter extends Filter {
/** The value of the 'Access-Control-Allow-Origin' header. Default is '*'. */
private Set<String> allowedOrigins = SetUtils.newHashSet("*");

/**
* Optional additional rule to accept an origin that is not listed in {@link #allowedOrigins}.
*/
private Predicate<String> allowedOriginMatcher = null;

/** Helper for generating CORS response. */
private CorsResponseHelper corsResponseHelper;
private volatile CorsResponseHelper corsResponseHelper;

/**
* The set of methods allowed by default, used when {@link #skippingResourceForCorsOptions} is
Expand Down Expand Up @@ -114,6 +127,15 @@ public CorsFilter(Context context, Restlet next) {
*/
@Override
protected void afterHandle(Request request, Response response) {
try {
CorsResponseHelper.checkCredentialsConfiguration(allowedCredentials, allowedOrigins);
} catch (IllegalStateException e) {
// Never add CORS headers with an unsafe configuration, even if the filter
// could not be started
getLogger().log(Level.SEVERE, "CORS headers not added", e);
return;
}

getCorsResponseHelper().addCorsResponseHeaders(request, response);
}

Expand Down Expand Up @@ -163,19 +185,34 @@ public Set<String> getAllowedOrigins() {

/**
* Returns a lazy-initialized instance of {@link
* org.restlet.engine.application.CorsResponseHelper}.
* org.restlet.engine.application.CorsResponseHelper}. The instance is rebuilt after any change
* of the configuration of this filter.
*/
protected CorsResponseHelper getCorsResponseHelper() {
if (corsResponseHelper == null) {
corsResponseHelper = new CorsResponseHelper();
corsResponseHelper.setAllowedCredentials(allowedCredentials);
corsResponseHelper.setAllowedOrigins(allowedOrigins);
corsResponseHelper.setAllowAllRequestedHeaders(allowAllRequestedHeaders);
corsResponseHelper.setAllowedHeaders(allowedHeaders);
corsResponseHelper.setExposedHeaders(exposedHeaders);
corsResponseHelper.setMaxAge(maxAge);
CorsResponseHelper helper = corsResponseHelper;
if (helper == null) {
helper = new CorsResponseHelper();
helper.setAllowedCredentials(allowedCredentials);
helper.setAllowedOrigins(allowedOrigins);
helper.setAllowedOriginMatcher(allowedOriginMatcher);
helper.setAllowAllRequestedHeaders(allowAllRequestedHeaders);
helper.setAllowedHeaders(allowedHeaders);
helper.setExposedHeaders(exposedHeaders);
helper.setMaxAge(maxAge);
corsResponseHelper = helper;
}
return corsResponseHelper;
return helper;
}

/**
* Checks the configuration before starting.
*
* @throws IllegalStateException If credentials are allowed together with the "*" origin.
*/
@Override
public synchronized void start() throws Exception {
CorsResponseHelper.checkCredentialsConfiguration(allowedCredentials, allowedOrigins);
super.start();
}

/**
Expand Down Expand Up @@ -254,6 +291,7 @@ public boolean isSkippingResourceForCorsOptions() {
*/
public CorsFilter setAllowedCredentials(boolean allowedCredentials) {
this.allowedCredentials = allowedCredentials;
this.corsResponseHelper = null;
return this;
}

Expand All @@ -266,9 +304,34 @@ public CorsFilter setAllowedCredentials(boolean allowedCredentials) {
*/
public CorsFilter setAllowedHeaders(Set<String> allowedHeaders) {
this.allowedHeaders = allowedHeaders;
this.corsResponseHelper = null;
return this;
}

/**
* Sets an optional rule to accept origins that are not listed in the allowed origins, for
* example {@code origin -> origin.endsWith(".example.com")}. The response always contains the
* exact request origin. The set of allowed origins must not contain "*", otherwise every origin
* is accepted.
*
* @param allowedOriginMatcher The origin matcher, or null for none.
* @return Itself for chaining methods calls.
*/
public CorsFilter setAllowedOriginMatcher(Predicate<String> allowedOriginMatcher) {
this.allowedOriginMatcher = allowedOriginMatcher;
this.corsResponseHelper = null;
return this;
}

/**
* Returns the optional rule used to accept origins that are not listed in the allowed origins.
*
* @return The origin matcher, or null.
*/
public Predicate<String> getAllowedOriginMatcher() {
return allowedOriginMatcher;
}

/**
* Sets the value of the 'Access-Control-Allow-Origin' header.
*
Expand All @@ -277,6 +340,7 @@ public CorsFilter setAllowedHeaders(Set<String> allowedHeaders) {
*/
public CorsFilter setAllowedOrigins(Set<String> allowedOrigins) {
this.allowedOrigins = allowedOrigins;
this.corsResponseHelper = null;
return this;
}

Expand All @@ -291,6 +355,7 @@ public CorsFilter setAllowedOrigins(Set<String> allowedOrigins) {
*/
public CorsFilter setAllowingAllRequestedHeaders(boolean allowingAllRequestedHeaders) {
this.allowAllRequestedHeaders = allowingAllRequestedHeaders;
this.corsResponseHelper = null;
return this;
}

Expand All @@ -315,6 +380,7 @@ public CorsFilter setDefaultAllowedMethods(Set<Method> defaultAllowedMethods) {
*/
public CorsFilter setExposedHeaders(Set<String> exposedHeaders) {
this.exposedHeaders = exposedHeaders;
this.corsResponseHelper = null;
return this;
}

Expand All @@ -326,6 +392,7 @@ public CorsFilter setExposedHeaders(Set<String> exposedHeaders) {
*/
public CorsFilter setMaxAge(int maxAge) {
this.maxAge = maxAge;
this.corsResponseHelper = null;
return this;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@

import java.util.HashSet;
import java.util.Set;
import java.util.function.Predicate;
import java.util.logging.Logger;
import org.restlet.Context;
import org.restlet.Request;
import org.restlet.Response;
import org.restlet.data.Dimension;
import org.restlet.data.Method;
import org.restlet.data.Status;
import org.restlet.engine.util.SetUtils;
Expand All @@ -23,8 +25,11 @@
* The CORS specification defines a subset of methods qualified as simple HEAD, GET and POST. Any
* other methods should send a preflight request with the method OPTIONS.
*
* @see <a href="http://www.w3.org/TR/cors">W3C CORS Specification</a>
* @see <a href="http://www.w3.org/TR/cors/#simple-method">Simple methods</a>
* @see <a href="https://fetch.spec.whatwg.org/#http-cors-protocol">Fetch Standard, CORS
* protocol</a>
* @see <a href="https://fetch.spec.whatwg.org/#cors-protocol-and-http-caches">Fetch Standard, CORS
* protocol and HTTP caches</a>
* @see <a href="https://www.rfc-editor.org/rfc/rfc9110#section-12.5.5">RFC 9110, Vary</a>
* @author Manuel Boillod
*/
public class CorsResponseHelper {
Expand All @@ -50,6 +55,12 @@ public class CorsResponseHelper {
/** The value of the 'Access-Control-Allow-Origin' header. Default is '*'. */
private Set<String> allowedOrigins = SetUtils.newHashSet("*");

/**
* Optional additional rule to accept an origin that is not listed in {@link #allowedOrigins}
* (for example to accept any sub-domain). Default is null.
*/
private Predicate<String> allowedOriginMatcher = null;

/** The value of 'Access-Control-Expose-Headers' response header. */
private Set<String> exposedHeaders = null;

Expand All @@ -66,6 +77,14 @@ public class CorsResponseHelper {
*/
public void addCorsResponseHeaders(Request request, Response response) {

// When the answer depends on the Origin request header (allow-list or credentials),
// caches must key on it, including for requests without Origin, whose response lacks
// CORS headers (Fetch standard, "CORS protocol and HTTP caches"; RFC 9110, 12.5.5).
// A static "*" must not use Vary.
if (allowedCredentials || !allowedOrigins.contains("*")) {
response.getDimensions().add(Dimension.ORIGIN);
}

String origin = request.getHeaders().getFirstValue("Origin", true);

if (origin == null) {
Expand All @@ -77,7 +96,7 @@ public void addCorsResponseHeaders(Request request, Response response) {
// Header 'Allow' is not relevant in a CORS request.
response.getAllowedMethods().clear();

if (!allowedOrigins.contains("*") && !allowedOrigins.contains(origin)) {
if (!isOriginAllowed(origin)) {
// Origin isn't allowed
LOGGER.fine(() -> "Origin " + origin + " not allowed for CORS request");
return;
Expand Down Expand Up @@ -168,6 +187,48 @@ public void addCorsResponseHeaders(Request request, Response response) {
}
}

/**
* Checks that the credentials setting is compatible with the allowed origins. The Fetch
* standard does not accept the wildcard "*" in 'Access-Control-Allow-Origin' for requests with
* credentials, so the request origin would be echoed back and any web site could send
* credentialed requests.
*
* @param allowedCredentials True if credentials are allowed.
* @param allowedOrigins The allowed origins.
* @throws IllegalStateException If credentials are allowed together with the "*" origin.
*/
public static void checkCredentialsConfiguration(
boolean allowedCredentials, Set<String> allowedOrigins) {
if (allowedCredentials && allowedOrigins != null && allowedOrigins.contains("*")) {
throw new IllegalStateException(
"CORS: credentials cannot be allowed together with the '*' origin, because"
+ " any web site could then send credentialed requests. List the"
+ " allowed origins explicitly.");
}
}

/**
* Returns the optional rule used to accept origins that are not listed in the allowed origins.
*
* @return The origin matcher, or null.
*/
public Predicate<String> getAllowedOriginMatcher() {
return allowedOriginMatcher;
}

/**
* Indicates if the given request origin is allowed: "*", a listed origin, or accepted by the
* origin matcher. The comparison is exact and case-sensitive, as in the Fetch standard.
*
* @param origin The value of the 'Origin' request header.
* @return True if the origin is allowed.
*/
private boolean isOriginAllowed(String origin) {
return allowedOrigins.contains("*")
|| allowedOrigins.contains(origin)
|| (allowedOriginMatcher != null && allowedOriginMatcher.test(origin));
}

/**
* Returns the modifiable set of headers allowed by the actual request on the current resource.
* <br>
Expand Down Expand Up @@ -301,6 +362,18 @@ public void setAllowedHeaders(Set<String> allowedHeaders) {
this.allowedHeaders = allowedHeaders;
}

/**
* Sets an optional rule to accept origins that are not listed in the allowed origins, for
* example {@code origin -> origin.endsWith(".example.com")}. The response always contains the
* exact request origin, as required by the Fetch standard. The set of allowed origins must not
* contain "*", otherwise every origin is accepted.
*
* @param allowedOriginMatcher The origin matcher, or null for none.
*/
public void setAllowedOriginMatcher(Predicate<String> allowedOriginMatcher) {
this.allowedOriginMatcher = allowedOriginMatcher;
}

/**
* Sets the value of the 'Access-Control-Allow-Origin' header.
*
Expand Down
Loading
Loading