From ad7e534461577b7def1ee0c141410016662d24c6 Mon Sep 17 00:00:00 2001 From: Thierry Boileau Date: Sat, 31 Oct 2026 17:59:39 +0100 Subject: [PATCH 1/2] CORS: reject '*' with credentials, add Vary: Origin and an origin matcher - Allowing credentials together with the '*' origin is rejected with an IllegalStateException (CorsService.createInboundFilter, CorsFilter.start); CorsFilter adds no CORS headers with such a configuration. - Vary: Origin is added when the CORS answer depends on the request origin (origin allow-list or credentials), including for requests without Origin; a static '*' without credentials sends no Vary (Fetch standard). - CorsFilter setters now apply after the first request (the helper is rebuilt). - Optional origin matcher (setAllowedOriginMatcher). - Restlet.handle() no longer throws a NullPointerException when a restlet without context cannot be started. - Javadoc references the Fetch standard instead of the W3C CORS draft. Refs #1611 Co-Authored-By: Claude Sonnet 5.5 --- changes.md | 9 + .../src/main/java/org/restlet/Restlet.java | 8 +- .../engine/application/CorsFilter.java | 89 ++++++- .../application/CorsResponseHelper.java | 79 ++++++- .../java/org/restlet/service/CorsService.java | 34 +++ .../CorsResponseFilterTestCase.java | 220 ++++++++++++++++++ .../restlet/service/CorsServiceTestCase.java | 22 ++ 7 files changed, 445 insertions(+), 16 deletions(-) diff --git a/changes.md b/changes.md index 936b8106cc..e122ee2a97 100644 --- a/changes.md +++ b/changes.md @@ -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 @@ -103,6 +109,7 @@ 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)`) 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 @@ -110,8 +117,10 @@ Changes log - 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. diff --git a/org.restlet/src/main/java/org/restlet/Restlet.java b/org.restlet/src/main/java/org/restlet/Restlet.java index c63c42e28a..cc289236c7 100644 --- a/org.restlet/src/main/java/org/restlet/Restlet.java +++ b/org.restlet/src/main/java/org/restlet/Restlet.java @@ -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); } } diff --git a/org.restlet/src/main/java/org/restlet/engine/application/CorsFilter.java b/org.restlet/src/main/java/org/restlet/engine/application/CorsFilter.java index 78929d2ada..231af62fe8 100644 --- a/org.restlet/src/main/java/org/restlet/engine/application/CorsFilter.java +++ b/org.restlet/src/main/java/org/restlet/engine/application/CorsFilter.java @@ -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; @@ -23,6 +25,12 @@ * Filter that helps support CORS requests. This filter lets the target resources specify the * allowed methods. * + *

Credentials: 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. + * *

Example: * *

@@ -56,8 +64,13 @@ public class CorsFilter extends Filter {
     /** The value of the 'Access-Control-Allow-Origin' header. Default is '*'. */
     private Set allowedOrigins = SetUtils.newHashSet("*");
 
+    /**
+     * Optional additional rule to accept an origin that is not listed in {@link #allowedOrigins}.
+     */
+    private Predicate 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
@@ -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);
     }
 
@@ -163,19 +185,34 @@ public Set 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();
     }
 
     /**
@@ -254,6 +291,7 @@ public boolean isSkippingResourceForCorsOptions() {
      */
     public CorsFilter setAllowedCredentials(boolean allowedCredentials) {
         this.allowedCredentials = allowedCredentials;
+        this.corsResponseHelper = null;
         return this;
     }
 
@@ -266,9 +304,34 @@ public CorsFilter setAllowedCredentials(boolean allowedCredentials) {
      */
     public CorsFilter setAllowedHeaders(Set 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 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 getAllowedOriginMatcher() {
+        return allowedOriginMatcher;
+    }
+
     /**
      * Sets the value of the 'Access-Control-Allow-Origin' header.
      *
@@ -277,6 +340,7 @@ public CorsFilter setAllowedHeaders(Set allowedHeaders) {
      */
     public CorsFilter setAllowedOrigins(Set allowedOrigins) {
         this.allowedOrigins = allowedOrigins;
+        this.corsResponseHelper = null;
         return this;
     }
 
@@ -291,6 +355,7 @@ public CorsFilter setAllowedOrigins(Set allowedOrigins) {
      */
     public CorsFilter setAllowingAllRequestedHeaders(boolean allowingAllRequestedHeaders) {
         this.allowAllRequestedHeaders = allowingAllRequestedHeaders;
+        this.corsResponseHelper = null;
         return this;
     }
 
@@ -315,6 +380,7 @@ public CorsFilter setDefaultAllowedMethods(Set defaultAllowedMethods) {
      */
     public CorsFilter setExposedHeaders(Set exposedHeaders) {
         this.exposedHeaders = exposedHeaders;
+        this.corsResponseHelper = null;
         return this;
     }
 
@@ -326,6 +392,7 @@ public CorsFilter setExposedHeaders(Set exposedHeaders) {
      */
     public CorsFilter setMaxAge(int maxAge) {
         this.maxAge = maxAge;
+        this.corsResponseHelper = null;
         return this;
     }
 
diff --git a/org.restlet/src/main/java/org/restlet/engine/application/CorsResponseHelper.java b/org.restlet/src/main/java/org/restlet/engine/application/CorsResponseHelper.java
index 381c800b05..423998574b 100644
--- a/org.restlet/src/main/java/org/restlet/engine/application/CorsResponseHelper.java
+++ b/org.restlet/src/main/java/org/restlet/engine/application/CorsResponseHelper.java
@@ -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;
@@ -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 W3C CORS Specification
- * @see Simple methods
+ * @see Fetch Standard, CORS
+ *     protocol
+ * @see Fetch Standard, CORS
+ *     protocol and HTTP caches
+ * @see RFC 9110, Vary
  * @author Manuel Boillod
  */
 public class CorsResponseHelper {
@@ -50,6 +55,12 @@ public class CorsResponseHelper {
     /** The value of the 'Access-Control-Allow-Origin' header. Default is '*'. */
     private Set 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 allowedOriginMatcher = null;
+
     /** The value of 'Access-Control-Expose-Headers' response header. */
     private Set exposedHeaders = null;
 
@@ -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) {
@@ -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;
@@ -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 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 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.
      * 
@@ -301,6 +362,18 @@ public void setAllowedHeaders(Set 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 allowedOriginMatcher) { + this.allowedOriginMatcher = allowedOriginMatcher; + } + /** * Sets the value of the 'Access-Control-Allow-Origin' header. * diff --git a/org.restlet/src/main/java/org/restlet/service/CorsService.java b/org.restlet/src/main/java/org/restlet/service/CorsService.java index 766f8aabfe..d696997629 100644 --- a/org.restlet/src/main/java/org/restlet/service/CorsService.java +++ b/org.restlet/src/main/java/org/restlet/service/CorsService.java @@ -11,9 +11,11 @@ import java.util.Arrays; import java.util.HashSet; import java.util.Set; +import java.util.function.Predicate; import org.restlet.Context; import org.restlet.data.Method; import org.restlet.engine.application.CorsFilter; +import org.restlet.engine.application.CorsResponseHelper; import org.restlet.engine.util.SetUtils; import org.restlet.routing.Filter; @@ -21,6 +23,12 @@ * Application service that adds support of CORS. This service lets the target resource specify the * allowed methods. * + *

Credentials: 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. + * *

Example: * *

@@ -45,6 +53,9 @@ public class CorsService extends Service {
     /** The value of the 'Access-Control-Allow-Origin' header. Default is '*'. */
     private Set allowedOrigins = SetUtils.newHashSet("*");
 
+    /** Optional additional rule to accept an origin that is not listed in the allowed origins. */
+    private Predicate allowedOriginMatcher = null;
+
     /**
      * If true, copies the value of 'Access-Control-Request-Headers' request header into the
      * 'Access-Control-Allow-Headers' response header. If false, use {@link #allowedHeaders}.
@@ -93,7 +104,9 @@ public CorsService(boolean enabled) {
 
     @Override
     public Filter createInboundFilter(Context context) {
+        CorsResponseHelper.checkCredentialsConfiguration(allowedCredentials, allowedOrigins);
         return new CorsFilter()
+                .setAllowedOriginMatcher(allowedOriginMatcher)
                 .setAllowedCredentials(allowedCredentials)
                 .setAllowedOrigins(allowedOrigins)
                 .setAllowingAllRequestedHeaders(allowingAllRequestedHeaders)
@@ -216,6 +229,27 @@ public void setAllowedHeaders(Set 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. 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 allowedOriginMatcher) {
+        this.allowedOriginMatcher = allowedOriginMatcher;
+    }
+
+    /**
+     * Returns the optional rule used to accept origins that are not listed in the allowed origins.
+     *
+     * @return The origin matcher, or null.
+     */
+    public Predicate getAllowedOriginMatcher() {
+        return allowedOriginMatcher;
+    }
+
     /**
      * Sets the value of the 'Access-Control-Allow-Origin' header.
      *
diff --git a/org.restlet/src/test/java/org/restlet/engine/application/CorsResponseFilterTestCase.java b/org.restlet/src/test/java/org/restlet/engine/application/CorsResponseFilterTestCase.java
index 9bde9e770e..f3e4aa9a92 100644
--- a/org.restlet/src/test/java/org/restlet/engine/application/CorsResponseFilterTestCase.java
+++ b/org.restlet/src/test/java/org/restlet/engine/application/CorsResponseFilterTestCase.java
@@ -10,6 +10,7 @@
 
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -94,6 +95,7 @@ void testGet() {
 
     @Test
     void testGet_withAuthenticationAllowed() {
+        corsFilter.setAllowedOrigins(java.util.Collections.singleton("localhost"));
         corsFilter.setAllowedCredentials(true);
 
         Request request = new Request();
@@ -163,6 +165,7 @@ void testOption_requestPost_skippingResource() {
 
     @Test
     void testOption_requestGet_withAuthenticationAllowed() {
+        corsFilter.setAllowedOrigins(java.util.Collections.singleton("localhost"));
         corsFilter.setAllowedCredentials(true);
 
         Request request = new Request();
@@ -179,6 +182,223 @@ void testOption_requestGet_withAuthenticationAllowed() {
         assertIsEmpty(response.getAccessControlExposeHeaders());
     }
 
+    // VARY: ORIGIN (Fetch standard, "CORS protocol and HTTP caches"; RFC 9110 section 12.5.5)
+
+    /** Static "*" without credentials: same answer for everybody, Vary must not be used. */
+    @Test
+    void testGet_staticWildcard_noVary() {
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        request.getHeaders().set("Origin", "localhost");
+        Response response = corsFilter.handle(request);
+        assertFalse(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    @Test
+    void testOption_requestGet_staticWildcard_noVary() {
+        Request request = new Request();
+        request.setMethod(Method.OPTIONS);
+        request.getHeaders().set("Origin", "localhost");
+        request.setAccessControlRequestMethod(Method.GET);
+        Response response = corsFilter.handle(request);
+        assertFalse(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    @Test
+    void testGet_staticWildcard_withoutOrigin_noVary() {
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        Response response = corsFilter.handle(request);
+        assertFalse(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    @Test
+    void testGet_withAuthenticationAllowed_varyOrigin() {
+        corsFilter.setAllowedOrigins(java.util.Collections.singleton("localhost"));
+        corsFilter.setAllowedCredentials(true);
+
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        request.getHeaders().set("Origin", "localhost");
+        Response response = corsFilter.handle(request);
+        assertTrue(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    @Test
+    void testOption_requestGet_withAuthenticationAllowed_varyOrigin() {
+        corsFilter.setAllowedOrigins(java.util.Collections.singleton("localhost"));
+        corsFilter.setAllowedCredentials(true);
+
+        Request request = new Request();
+        request.setMethod(Method.OPTIONS);
+        request.getHeaders().set("Origin", "localhost");
+        request.setAccessControlRequestMethod(Method.GET);
+        Response response = corsFilter.handle(request);
+        assertTrue(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    @Test
+    void testGet_allowList_allowedOrigin_varyOrigin() {
+        corsFilter.setAllowedOrigins(java.util.Collections.singleton("http://allowed.example"));
+
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        request.getHeaders().set("Origin", "http://allowed.example");
+        Response response = corsFilter.handle(request);
+        assertEquals("http://allowed.example", response.getAccessControlAllowOrigin());
+        assertTrue(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    @Test
+    void testGet_allowList_originNotAllowed_varyOrigin() {
+        corsFilter.setAllowedOrigins(java.util.Collections.singleton("http://allowed.example"));
+
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        request.getHeaders().set("Origin", "http://other.example");
+        Response response = corsFilter.handle(request);
+        assertNoCorsHeaders(response);
+        // A cache must not reuse this answer for the allowed origin
+        assertTrue(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    /**
+     * Without Origin, the response lacks CORS headers: a cache must not serve it later to a CORS
+     * request.
+     */
+    @Test
+    void testGet_allowList_withoutOrigin_varyOrigin() {
+        corsFilter.setAllowedOrigins(java.util.Collections.singleton("http://allowed.example"));
+
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        Response response = corsFilter.handle(request);
+        assertNoCorsHeaders(response);
+        assertTrue(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    @Test
+    void testGet_withAuthenticationAllowed_withoutOrigin_varyOrigin() {
+        corsFilter.setAllowedOrigins(java.util.Collections.singleton("localhost"));
+        corsFilter.setAllowedCredentials(true);
+
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        Response response = corsFilter.handle(request);
+        assertNoCorsHeaders(response);
+        assertTrue(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    // CONFIGURATION CHANGES AFTER THE FIRST REQUEST
+
+    @Test
+    void testSetAllowedOrigins_afterFirstRequest_isApplied() {
+        Request first = new Request();
+        first.setMethod(Method.GET);
+        first.getHeaders().set("Origin", "http://other.example");
+        assertEquals("*", corsFilter.handle(first).getAccessControlAllowOrigin());
+
+        corsFilter.setAllowedOrigins(java.util.Collections.singleton("http://allowed.example"));
+
+        Request second = new Request();
+        second.setMethod(Method.GET);
+        second.getHeaders().set("Origin", "http://other.example");
+        assertNoCorsHeaders(corsFilter.handle(second));
+    }
+
+    // CREDENTIALS AND ORIGIN MATCHING
+
+    @Test
+    void testCredentialsWithWildcardOrigin_isRejected() {
+        corsFilter.setAllowedOrigins(new java.util.HashSet<>(java.util.List.of("*")));
+        corsFilter.setAllowedCredentials(true);
+        IllegalStateException e =
+                org.junit.jupiter.api.Assertions.assertThrows(
+                        IllegalStateException.class, () -> corsFilter.start());
+        assertTrue(e.getMessage().contains("'*'"));
+    }
+
+    @Test
+    void testCredentialsWithWildcardOrigin_failsOnFirstRequest() {
+        corsFilter.setAllowedCredentials(true);
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        request.getHeaders().set("Origin", "http://any.example");
+        Response response = corsFilter.handle(request);
+        assertNoCorsHeaders(response);
+        assertTrue(response.getStatus().isServerError());
+    }
+
+    @Test
+    void testSetCredentialsBeforeOrigins_isAccepted() throws Exception {
+        // the check is done at start, so the order of the setters does not matter
+        corsFilter.setAllowedCredentials(true);
+        corsFilter.setAllowedOrigins(java.util.Collections.singleton("http://allowed.example"));
+        corsFilter.start();
+    }
+
+    @Test
+    void testOriginComparison_isCaseSensitive() {
+        corsFilter.setAllowedOrigins(java.util.Collections.singleton("http://allowed.example"));
+
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        request.getHeaders().set("Origin", "http://ALLOWED.example");
+        assertNoCorsHeaders(corsFilter.handle(request));
+    }
+
+    @Test
+    void testNullOrigin_matchedByWildcardOnly() {
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        request.getHeaders().set("Origin", "null");
+        assertEquals("*", corsFilter.handle(request).getAccessControlAllowOrigin());
+
+        corsFilter.setAllowedOrigins(java.util.Collections.singleton("http://allowed.example"));
+        Request other = new Request();
+        other.setMethod(Method.GET);
+        other.getHeaders().set("Origin", "null");
+        assertNoCorsHeaders(corsFilter.handle(other));
+    }
+
+    @Test
+    void testOriginMatcher_acceptsMatchingOrigin_echoesExactOrigin() {
+        corsFilter.setAllowedOrigins(new java.util.HashSet());
+        corsFilter.setAllowedOriginMatcher(origin -> origin.endsWith(".example.com"));
+
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        request.getHeaders().set("Origin", "https://app.example.com");
+        Response response = corsFilter.handle(request);
+        assertEquals("https://app.example.com", response.getAccessControlAllowOrigin());
+        assertTrue(response.getDimensions().contains(org.restlet.data.Dimension.ORIGIN));
+    }
+
+    @Test
+    void testOriginMatcher_rejectsOtherOrigin() {
+        corsFilter.setAllowedOrigins(new java.util.HashSet());
+        corsFilter.setAllowedOriginMatcher(origin -> origin.endsWith(".example.com"));
+
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        request.getHeaders().set("Origin", "https://evil.example.org");
+        assertNoCorsHeaders(corsFilter.handle(request));
+    }
+
+    @Test
+    void testOriginMatcher_withCredentials() {
+        corsFilter.setAllowedOrigins(new java.util.HashSet());
+        corsFilter.setAllowedOriginMatcher(origin -> origin.endsWith(".example.com"));
+        corsFilter.setAllowedCredentials(true);
+
+        Request request = new Request();
+        request.setMethod(Method.GET);
+        request.getHeaders().set("Origin", "https://app.example.com");
+        Response response = corsFilter.handle(request);
+        assertEquals("https://app.example.com", response.getAccessControlAllowOrigin());
+        assertEquals(Boolean.TRUE, response.getAccessControlAllowCredentials());
+    }
+
     private void assertIsEmpty(Collection collection) {
         assertNotNull(collection);
         assertTrue(collection.isEmpty());
diff --git a/org.restlet/src/test/java/org/restlet/service/CorsServiceTestCase.java b/org.restlet/src/test/java/org/restlet/service/CorsServiceTestCase.java
index 1032ab801e..59a2b346c9 100644
--- a/org.restlet/src/test/java/org/restlet/service/CorsServiceTestCase.java
+++ b/org.restlet/src/test/java/org/restlet/service/CorsServiceTestCase.java
@@ -11,6 +11,8 @@
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.util.Set;
@@ -80,4 +82,24 @@ void createInboundFilter_returnsConfiguredCorsFilter() {
 
         assertInstanceOf(CorsFilter.class, filter);
     }
+
+    @Test
+    void createInboundFilter_rejectsCredentialsWithWildcardOrigin() {
+        CorsService service = new CorsService();
+        service.setAllowedCredentials(true);
+
+        assertThrows(IllegalStateException.class, () -> service.createInboundFilter(new Context()));
+    }
+
+    @Test
+    void createInboundFilter_transmitsOriginMatcher() {
+        CorsService service = new CorsService();
+        service.setAllowedOrigins(Set.of());
+        service.setAllowedOriginMatcher(origin -> origin.endsWith(".example.com"));
+
+        CorsFilter filter = (CorsFilter) service.createInboundFilter(new Context());
+
+        assertNotNull(filter.getAllowedOriginMatcher());
+        assertTrue(filter.getAllowedOriginMatcher().test("https://a.example.com"));
+    }
 }

From 9311fd4f80016d66a1c1a3028e9be28eefea67a5 Mon Sep 17 00:00:00 2001
From: Thierry Boileau 
Date: Sat, 31 Oct 2026 18:21:27 +0100
Subject: [PATCH 2/2] CORS tests: fix the two Sonar warnings (S2699, S5778)

Refs #1611

Co-Authored-By: Claude Sonnet 5.5 
---
 .../engine/application/CorsResponseFilterTestCase.java        | 1 +
 .../test/java/org/restlet/service/CorsServiceTestCase.java    | 4 +++-
 2 files changed, 4 insertions(+), 1 deletion(-)

diff --git a/org.restlet/src/test/java/org/restlet/engine/application/CorsResponseFilterTestCase.java b/org.restlet/src/test/java/org/restlet/engine/application/CorsResponseFilterTestCase.java
index f3e4aa9a92..8092718f94 100644
--- a/org.restlet/src/test/java/org/restlet/engine/application/CorsResponseFilterTestCase.java
+++ b/org.restlet/src/test/java/org/restlet/engine/application/CorsResponseFilterTestCase.java
@@ -335,6 +335,7 @@ void testSetCredentialsBeforeOrigins_isAccepted() throws Exception {
         corsFilter.setAllowedCredentials(true);
         corsFilter.setAllowedOrigins(java.util.Collections.singleton("http://allowed.example"));
         corsFilter.start();
+        assertTrue(corsFilter.isStarted());
     }
 
     @Test
diff --git a/org.restlet/src/test/java/org/restlet/service/CorsServiceTestCase.java b/org.restlet/src/test/java/org/restlet/service/CorsServiceTestCase.java
index 59a2b346c9..d6e29e76b4 100644
--- a/org.restlet/src/test/java/org/restlet/service/CorsServiceTestCase.java
+++ b/org.restlet/src/test/java/org/restlet/service/CorsServiceTestCase.java
@@ -88,7 +88,9 @@ void createInboundFilter_rejectsCredentialsWithWildcardOrigin() {
         CorsService service = new CorsService();
         service.setAllowedCredentials(true);
 
-        assertThrows(IllegalStateException.class, () -> service.createInboundFilter(new Context()));
+        Context context = new Context();
+
+        assertThrows(IllegalStateException.class, () -> service.createInboundFilter(context));
     }
 
     @Test